diff --git a/x-pack/plugins/licensing/common/license_update.ts b/x-pack/plugins/licensing/common/license_update.ts index b344d8ce2d16a..a35b7aa6e6785 100644 --- a/x-pack/plugins/licensing/common/license_update.ts +++ b/x-pack/plugins/licensing/common/license_update.ts @@ -17,6 +17,7 @@ import { takeUntil, finalize, startWith, + throttleTime, } from 'rxjs/operators'; import { hasLicenseInfoChanged } from './has_license_info_changed'; import type { ILicense } from './types'; @@ -29,11 +30,15 @@ export function createLicenseUpdate( ) { const manuallyRefresh$ = new Subject(); - const fetched$ = merge(triggerRefresh$, manuallyRefresh$).pipe( - takeUntil(stop$), - exhaustMap(fetcher), - share() - ); + const fetched$ = merge( + triggerRefresh$, + manuallyRefresh$.pipe( + throttleTime(1000, undefined, { + leading: true, + trailing: true, + }) + ) + ).pipe(takeUntil(stop$), exhaustMap(fetcher), share()); // provide a first, empty license, so that we can compare in the filter below const startWithArgs = initialValues ? [undefined, initialValues] : [undefined]; diff --git a/x-pack/plugins/licensing/server/license_fetcher.test.ts b/x-pack/plugins/licensing/server/license_fetcher.test.ts new file mode 100644 index 0000000000000..efd9b001fa0ff --- /dev/null +++ b/x-pack/plugins/licensing/server/license_fetcher.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { getLicenseFetcher } from './license_fetcher'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; + +type EsLicense = estypes.XpackInfoMinimalLicenseInformation; + +const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); + +function buildRawLicense(options: Partial = {}): EsLicense { + return { + uid: 'uid-000000001234', + status: 'active', + type: 'basic', + mode: 'basic', + expiry_date_in_millis: 1000, + ...options, + }; +} + +describe('LicenseFetcher', () => { + let logger: MockedLogger; + let clusterClient: ReturnType; + + beforeEach(() => { + logger = loggerMock.create(); + clusterClient = elasticsearchServiceMock.createClusterClient(); + }); + + it('returns the license for successful calls', async () => { + clusterClient.asInternalUser.xpack.info.mockResponse({ + license: buildRawLicense({ + uid: 'license-1', + }), + features: {}, + } as any); + + const fetcher = getLicenseFetcher({ + logger, + clusterClient, + cacheDurationMs: 50_000, + }); + + const license = await fetcher(); + expect(license.uid).toEqual('license-1'); + }); + + it('returns the latest license for successful calls', async () => { + clusterClient.asInternalUser.xpack.info + .mockResponseOnce({ + license: buildRawLicense({ + uid: 'license-1', + }), + features: {}, + } as any) + .mockResponseOnce({ + license: buildRawLicense({ + uid: 'license-2', + }), + features: {}, + } as any); + + const fetcher = getLicenseFetcher({ + logger, + clusterClient, + cacheDurationMs: 50_000, + }); + + let license = await fetcher(); + expect(license.uid).toEqual('license-1'); + + license = await fetcher(); + expect(license.uid).toEqual('license-2'); + }); + + it('returns an error license in case of error', async () => { + clusterClient.asInternalUser.xpack.info.mockResponseImplementation(() => { + throw new Error('woups'); + }); + + const fetcher = getLicenseFetcher({ + logger, + clusterClient, + cacheDurationMs: 50_000, + }); + + const license = await fetcher(); + expect(license.error).toEqual('woups'); + }); + + it('returns a license successfully fetched after an error', async () => { + clusterClient.asInternalUser.xpack.info + .mockResponseImplementationOnce(() => { + throw new Error('woups'); + }) + .mockResponseOnce({ + license: buildRawLicense({ + uid: 'license-1', + }), + features: {}, + } as any); + + const fetcher = getLicenseFetcher({ + logger, + clusterClient, + cacheDurationMs: 50_000, + }); + + let license = await fetcher(); + expect(license.error).toEqual('woups'); + license = await fetcher(); + expect(license.uid).toEqual('license-1'); + }); + + it('returns the latest fetched license after an error within the cache duration period', async () => { + clusterClient.asInternalUser.xpack.info + .mockResponseOnce({ + license: buildRawLicense({ + uid: 'license-1', + }), + features: {}, + } as any) + .mockResponseImplementationOnce(() => { + throw new Error('woups'); + }); + + const fetcher = getLicenseFetcher({ + logger, + clusterClient, + cacheDurationMs: 50_000, + }); + + let license = await fetcher(); + expect(license.uid).toEqual('license-1'); + license = await fetcher(); + expect(license.uid).toEqual('license-1'); + }); + + it('returns an error license after an error exceeding the cache duration period', async () => { + clusterClient.asInternalUser.xpack.info + .mockResponseOnce({ + license: buildRawLicense({ + uid: 'license-1', + }), + features: {}, + } as any) + .mockResponseImplementationOnce(() => { + throw new Error('woups'); + }); + + const fetcher = getLicenseFetcher({ + logger, + clusterClient, + cacheDurationMs: 1, + }); + + let license = await fetcher(); + expect(license.uid).toEqual('license-1'); + + await delay(50); + + license = await fetcher(); + expect(license.error).toEqual('woups'); + }); +}); diff --git a/x-pack/plugins/licensing/server/license_fetcher.ts b/x-pack/plugins/licensing/server/license_fetcher.ts new file mode 100644 index 0000000000000..43d9c204bbf66 --- /dev/null +++ b/x-pack/plugins/licensing/server/license_fetcher.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { createHash } from 'crypto'; +import stringify from 'json-stable-stringify'; +import type { MaybePromise } from '@kbn/utility-types'; +import { isPromise } from '@kbn/std'; +import type { IClusterClient, Logger } from '@kbn/core/server'; +import type { + ILicense, + PublicLicense, + PublicFeatures, + LicenseType, + LicenseStatus, +} from '../common/types'; +import { License } from '../common/license'; +import type { ElasticsearchError, LicenseFetcher } from './types'; + +export const getLicenseFetcher = ({ + clusterClient, + logger, + cacheDurationMs, +}: { + clusterClient: MaybePromise; + logger: Logger; + cacheDurationMs: number; +}): LicenseFetcher => { + let currentLicense: ILicense | undefined; + let lastSuccessfulFetchTime: number | undefined; + + return async () => { + const client = isPromise(clusterClient) ? await clusterClient : clusterClient; + try { + const response = await client.asInternalUser.xpack.info(); + const normalizedLicense = + response.license && response.license.type !== 'missing' + ? normalizeServerLicense(response.license) + : undefined; + const normalizedFeatures = response.features + ? normalizeFeatures(response.features) + : undefined; + + const signature = sign({ + license: normalizedLicense, + features: normalizedFeatures, + error: '', + }); + + currentLicense = new License({ + license: normalizedLicense, + features: normalizedFeatures, + signature, + }); + lastSuccessfulFetchTime = Date.now(); + + return currentLicense; + } catch (error) { + logger.warn( + `License information could not be obtained from Elasticsearch due to ${error} error` + ); + + if (lastSuccessfulFetchTime && lastSuccessfulFetchTime + cacheDurationMs > Date.now()) { + return currentLicense!; + } else { + const errorMessage = getErrorMessage(error); + const signature = sign({ error: errorMessage }); + + return new License({ + error: getErrorMessage(error), + signature, + }); + } + } + }; +}; + +function normalizeServerLicense( + license: estypes.XpackInfoMinimalLicenseInformation +): PublicLicense { + return { + uid: license.uid, + type: license.type as LicenseType, + mode: license.mode as LicenseType, + expiryDateInMillis: + typeof license.expiry_date_in_millis === 'string' + ? parseInt(license.expiry_date_in_millis, 10) + : license.expiry_date_in_millis, + status: license.status as LicenseStatus, + }; +} + +function normalizeFeatures(rawFeatures: estypes.XpackInfoFeatures) { + const features: PublicFeatures = {}; + for (const [name, feature] of Object.entries(rawFeatures)) { + features[name] = { + isAvailable: feature.available, + isEnabled: feature.enabled, + }; + } + return features; +} + +function sign({ + license, + features, + error, +}: { + license?: PublicLicense; + features?: PublicFeatures; + error?: string; +}) { + return createHash('sha256') + .update( + stringify({ + license, + features, + error, + }) + ) + .digest('hex'); +} + +function getErrorMessage(error: ElasticsearchError): string { + if (error.status === 400) { + return 'X-Pack plugin is not installed on the Elasticsearch cluster.'; + } + return error.message; +} diff --git a/x-pack/plugins/licensing/server/licensing_config.ts b/x-pack/plugins/licensing/server/licensing_config.ts index 459c69b650dbb..66899602e04cb 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -10,12 +10,18 @@ import { PluginConfigDescriptor } from '@kbn/core/server'; const configSchema = schema.object({ api_polling_frequency: schema.duration({ defaultValue: '30s' }), + license_cache_duration: schema.duration({ + defaultValue: '300s', + validate: (value) => { + if (value.asMinutes() > 15) { + return 'license cache duration must be shorter than 15 minutes'; + } + }, + }), }); export type LicenseConfigType = TypeOf; export const config: PluginConfigDescriptor = { - schema: schema.object({ - api_polling_frequency: schema.duration({ defaultValue: '30s' }), - }), + schema: configSchema, }; diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index b087b6f3f03fa..129dc6aee66da 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -56,22 +56,23 @@ describe('licensing plugin', () => { return client; }; - describe('#start', () => { - describe('#license$', () => { - let plugin: LicensingPlugin; - let pluginInitContextMock: ReturnType; + let plugin: LicensingPlugin; + let pluginInitContextMock: ReturnType; - beforeEach(() => { - pluginInitContextMock = coreMock.createPluginInitializerContext({ - api_polling_frequency: moment.duration(100), - }); - plugin = new LicensingPlugin(pluginInitContextMock); - }); + beforeEach(() => { + pluginInitContextMock = coreMock.createPluginInitializerContext({ + api_polling_frequency: moment.duration(100), + license_cache_duration: moment.duration(1000), + }); + plugin = new LicensingPlugin(pluginInitContextMock); + }); - afterEach(async () => { - await plugin.stop(); - }); + afterEach(async () => { + await plugin?.stop(); + }); + describe('#start', () => { + describe('#license$', () => { it('returns license', async () => { const esClient = createEsClient({ license: buildRawLicense(), @@ -79,8 +80,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); const license = await firstValueFrom(license$); expect(license.isAvailable).toBe(true); }); @@ -92,8 +93,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); await firstValueFrom(license$); expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(1); @@ -111,8 +112,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray())); expect(first.type).toBe('basic'); @@ -125,8 +126,8 @@ describe('licensing plugin', () => { esClient.asInternalUser.xpack.info.mockRejectedValue(new Error('test')); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); const license = await firstValueFrom(license$); expect(license.isAvailable).toBe(false); @@ -140,8 +141,8 @@ describe('licensing plugin', () => { esClient.asInternalUser.xpack.info.mockRejectedValue(error); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); const license = await firstValueFrom(license$); expect(license.isAvailable).toBe(false); @@ -169,8 +170,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray())); @@ -186,8 +187,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - await plugin.start(); + plugin.setup(coreSetup); + plugin.start(); await flushPromises(); @@ -201,8 +202,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - await plugin.start(); + plugin.setup(coreSetup); + plugin.start(); await flushPromises(); @@ -229,8 +230,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray())); expect(first.signature === third.signature).toBe(true); @@ -239,16 +240,12 @@ describe('licensing plugin', () => { }); describe('#refresh', () => { - let plugin: LicensingPlugin; - afterEach(async () => { - await plugin.stop(); - }); - it('forces refresh immediately', async () => { plugin = new LicensingPlugin( coreMock.createPluginInitializerContext({ // disable polling mechanism api_polling_frequency: moment.duration(50000), + license_cache_duration: moment.duration(1000), }) ); const esClient = createEsClient({ @@ -257,31 +254,26 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { refresh, license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { refresh, license$ } = plugin.start(); expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(0); - await license$.pipe(take(1)).toPromise(); + await firstValueFrom(license$); expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(1); - refresh(); + await refresh(); await flushPromises(); expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(2); }); }); describe('#createLicensePoller', () => { - let plugin: LicensingPlugin; - - afterEach(async () => { - await plugin.stop(); - }); - it(`creates a poller fetching license from passed 'clusterClient' every 'api_polling_frequency' ms`, async () => { plugin = new LicensingPlugin( coreMock.createPluginInitializerContext({ api_polling_frequency: moment.duration(50000), + license_cache_duration: moment.duration(1000), }) ); @@ -290,8 +282,8 @@ describe('licensing plugin', () => { features: {}, }); const coreSetup = createCoreSetupWith(esClient); - await plugin.setup(coreSetup); - const { createLicensePoller, license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { createLicensePoller, license$ } = plugin.start(); const customClient = createEsClient({ license: buildRawLicense({ type: 'gold' }), @@ -313,19 +305,13 @@ describe('licensing plugin', () => { expect(customLicense.isAvailable).toBe(true); expect(customLicense.type).toBe('gold'); - expect(await license$.pipe(take(1)).toPromise()).not.toBe(customLicense); + expect(await firstValueFrom(license$)).not.toBe(customLicense); }); it('creates a poller with a manual refresh control', async () => { - plugin = new LicensingPlugin( - coreMock.createPluginInitializerContext({ - api_polling_frequency: moment.duration(100), - }) - ); - const coreSetup = coreMock.createSetup(); - await plugin.setup(coreSetup); - const { createLicensePoller } = await plugin.start(); + plugin.setup(coreSetup); + const { createLicensePoller } = plugin.start(); const customClient = createEsClient({ license: buildRawLicense({ type: 'gold' }), @@ -344,24 +330,10 @@ describe('licensing plugin', () => { }); describe('extends core contexts', () => { - let plugin: LicensingPlugin; - - beforeEach(() => { - plugin = new LicensingPlugin( - coreMock.createPluginInitializerContext({ - api_polling_frequency: moment.duration(100), - }) - ); - }); - - afterEach(async () => { - await plugin.stop(); - }); - it('provides a licensing context to http routes', async () => { const coreSetup = coreMock.createSetup(); - await plugin.setup(coreSetup); + plugin.setup(coreSetup); expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(` Array [ @@ -375,22 +347,10 @@ describe('licensing plugin', () => { }); describe('registers on pre-response interceptor', () => { - let plugin: LicensingPlugin; - - beforeEach(() => { - plugin = new LicensingPlugin( - coreMock.createPluginInitializerContext({ api_polling_frequency: moment.duration(100) }) - ); - }); - - afterEach(async () => { - await plugin.stop(); - }); - it('once', async () => { const coreSetup = coreMock.createSetup(); - await plugin.setup(coreSetup); + plugin.setup(coreSetup); expect(coreSetup.http.registerOnPreResponse).toHaveBeenCalledTimes(1); }); @@ -399,14 +359,9 @@ describe('licensing plugin', () => { describe('#stop', () => { it('stops polling', async () => { - const plugin = new LicensingPlugin( - coreMock.createPluginInitializerContext({ - api_polling_frequency: moment.duration(100), - }) - ); const coreSetup = coreMock.createSetup(); - await plugin.setup(coreSetup); - const { license$ } = await plugin.start(); + plugin.setup(coreSetup); + const { license$ } = plugin.start(); let completed = false; license$.subscribe({ complete: () => (completed = true) }); diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 0d21cd689bf46..b3ac583e7c81e 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -8,12 +8,7 @@ import type { Observable, Subject, Subscription } from 'rxjs'; import { ReplaySubject, timer } from 'rxjs'; import moment from 'moment'; -import { createHash } from 'crypto'; -import stringify from 'json-stable-stringify'; - -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { MaybePromise } from '@kbn/utility-types'; -import { isPromise } from '@kbn/std'; import type { CoreSetup, Logger, @@ -21,73 +16,17 @@ import type { PluginInitializerContext, IClusterClient, } from '@kbn/core/server'; - import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider'; -import type { - ILicense, - PublicLicense, - PublicFeatures, - LicenseType, - LicenseStatus, -} from '../common/types'; +import type { ILicense } from '../common/types'; import type { LicensingPluginSetup, LicensingPluginStart } from './types'; -import { License } from '../common/license'; import { createLicenseUpdate } from '../common/license_update'; - -import type { ElasticsearchError } from './types'; import { registerRoutes } from './routes'; import { FeatureUsageService } from './services'; - import type { LicenseConfigType } from './licensing_config'; import { createRouteHandlerContext } from './licensing_route_handler_context'; import { createOnPreResponseHandler } from './on_pre_response_handler'; import { getPluginStatus$ } from './plugin_status'; - -function normalizeServerLicense( - license: estypes.XpackInfoMinimalLicenseInformation -): PublicLicense { - return { - uid: license.uid, - type: license.type as LicenseType, - mode: license.mode as LicenseType, - expiryDateInMillis: - typeof license.expiry_date_in_millis === 'string' - ? parseInt(license.expiry_date_in_millis, 10) - : license.expiry_date_in_millis, - status: license.status as LicenseStatus, - }; -} - -function normalizeFeatures(rawFeatures: estypes.XpackInfoFeatures) { - const features: PublicFeatures = {}; - for (const [name, feature] of Object.entries(rawFeatures)) { - features[name] = { - isAvailable: feature.available, - isEnabled: feature.enabled, - }; - } - return features; -} - -function sign({ - license, - features, - error, -}: { - license?: PublicLicense; - features?: PublicFeatures; - error?: string; -}) { - return createHash('sha256') - .update( - stringify({ - license, - features, - error, - }) - ) - .digest('hex'); -} +import { getLicenseFetcher } from './license_fetcher'; /** * @public @@ -153,9 +92,16 @@ export class LicensingPlugin implements Plugin - this.fetchLicense(clusterClient) + const { license$, refreshManually } = createLicenseUpdate( + intervalRefresh$, + this.stop$, + licenseFetcher ); this.loggingSubscription = license$.subscribe((license) => @@ -178,50 +124,6 @@ export class LicensingPlugin implements Plugin): Promise => { - const client = isPromise(clusterClient) ? await clusterClient : clusterClient; - try { - const response = await client.asInternalUser.xpack.info(); - const normalizedLicense = - response.license && response.license.type !== 'missing' - ? normalizeServerLicense(response.license) - : undefined; - const normalizedFeatures = response.features - ? normalizeFeatures(response.features) - : undefined; - - const signature = sign({ - license: normalizedLicense, - features: normalizedFeatures, - error: '', - }); - - return new License({ - license: normalizedLicense, - features: normalizedFeatures, - signature, - }); - } catch (error) { - this.logger.warn( - `License information could not be obtained from Elasticsearch due to ${error} error` - ); - const errorMessage = this.getErrorMessage(error); - const signature = sign({ error: errorMessage }); - - return new License({ - error: this.getErrorMessage(error), - signature, - }); - } - }; - - private getErrorMessage(error: ElasticsearchError): string { - if (error.status === 400) { - return 'X-Pack plugin is not installed on the Elasticsearch cluster.'; - } - return error.message; - } - public start() { if (!this.refresh || !this.license$) { throw new Error('Setup has not been completed'); diff --git a/x-pack/plugins/licensing/server/types.ts b/x-pack/plugins/licensing/server/types.ts index 83b39cb663715..fcccdecb66c00 100644 --- a/x-pack/plugins/licensing/server/types.ts +++ b/x-pack/plugins/licensing/server/types.ts @@ -14,6 +14,8 @@ export interface ElasticsearchError extends Error { status?: number; } +export type LicenseFetcher = () => Promise; + /** * Result from remote request fetching raw feature set. * @internal diff --git a/x-pack/plugins/licensing/tsconfig.json b/x-pack/plugins/licensing/tsconfig.json index 323f77b3b0ebc..1deb735f99466 100644 --- a/x-pack/plugins/licensing/tsconfig.json +++ b/x-pack/plugins/licensing/tsconfig.json @@ -15,7 +15,8 @@ "@kbn/i18n", "@kbn/analytics-client", "@kbn/subscription-tracking", - "@kbn/core-analytics-browser" + "@kbn/core-analytics-browser", + "@kbn/logging-mocks" ], "exclude": ["target/**/*"] }