diff --git a/x-pack/plugins/licensing/common/license_update.ts b/x-pack/plugins/licensing/common/license_update.ts index a35b7aa6e6785..b344d8ce2d16a 100644 --- a/x-pack/plugins/licensing/common/license_update.ts +++ b/x-pack/plugins/licensing/common/license_update.ts @@ -17,7 +17,6 @@ import { takeUntil, finalize, startWith, - throttleTime, } from 'rxjs/operators'; import { hasLicenseInfoChanged } from './has_license_info_changed'; import type { ILicense } from './types'; @@ -30,15 +29,11 @@ export function createLicenseUpdate( ) { const manuallyRefresh$ = new Subject(); - const fetched$ = merge( - triggerRefresh$, - manuallyRefresh$.pipe( - throttleTime(1000, undefined, { - leading: true, - trailing: true, - }) - ) - ).pipe(takeUntil(stop$), exhaustMap(fetcher), share()); + const fetched$ = merge(triggerRefresh$, manuallyRefresh$).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 deleted file mode 100644 index efd9b001fa0ff..0000000000000 --- a/x-pack/plugins/licensing/server/license_fetcher.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/* - * 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 deleted file mode 100644 index 43d9c204bbf66..0000000000000 --- a/x-pack/plugins/licensing/server/license_fetcher.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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 66899602e04cb..459c69b650dbb 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -10,18 +10,12 @@ 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: configSchema, + schema: schema.object({ + api_polling_frequency: schema.duration({ defaultValue: '30s' }), + }), }; diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index 129dc6aee66da..b087b6f3f03fa 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -56,23 +56,22 @@ describe('licensing plugin', () => { return client; }; - let plugin: LicensingPlugin; - let pluginInitContextMock: ReturnType; + describe('#start', () => { + describe('#license$', () => { + let plugin: LicensingPlugin; + let pluginInitContextMock: ReturnType; - beforeEach(() => { - pluginInitContextMock = coreMock.createPluginInitializerContext({ - api_polling_frequency: moment.duration(100), - license_cache_duration: moment.duration(1000), - }); - plugin = new LicensingPlugin(pluginInitContextMock); - }); + beforeEach(() => { + pluginInitContextMock = coreMock.createPluginInitializerContext({ + api_polling_frequency: moment.duration(100), + }); + 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(), @@ -80,8 +79,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); const license = await firstValueFrom(license$); expect(license.isAvailable).toBe(true); }); @@ -93,8 +92,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); await firstValueFrom(license$); expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(1); @@ -112,8 +111,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray())); expect(first.type).toBe('basic'); @@ -126,8 +125,8 @@ describe('licensing plugin', () => { esClient.asInternalUser.xpack.info.mockRejectedValue(new Error('test')); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); const license = await firstValueFrom(license$); expect(license.isAvailable).toBe(false); @@ -141,8 +140,8 @@ describe('licensing plugin', () => { esClient.asInternalUser.xpack.info.mockRejectedValue(error); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); const license = await firstValueFrom(license$); expect(license.isAvailable).toBe(false); @@ -170,8 +169,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray())); @@ -187,8 +186,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - plugin.start(); + await plugin.setup(coreSetup); + await plugin.start(); await flushPromises(); @@ -202,8 +201,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - plugin.start(); + await plugin.setup(coreSetup); + await plugin.start(); await flushPromises(); @@ -230,8 +229,8 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray())); expect(first.signature === third.signature).toBe(true); @@ -240,12 +239,16 @@ 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({ @@ -254,26 +257,31 @@ describe('licensing plugin', () => { }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { refresh, license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { refresh, license$ } = await plugin.start(); expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(0); - await firstValueFrom(license$); + await license$.pipe(take(1)).toPromise(); expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(1); - await refresh(); + 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), }) ); @@ -282,8 +290,8 @@ describe('licensing plugin', () => { features: {}, }); const coreSetup = createCoreSetupWith(esClient); - plugin.setup(coreSetup); - const { createLicensePoller, license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { createLicensePoller, license$ } = await plugin.start(); const customClient = createEsClient({ license: buildRawLicense({ type: 'gold' }), @@ -305,13 +313,19 @@ describe('licensing plugin', () => { expect(customLicense.isAvailable).toBe(true); expect(customLicense.type).toBe('gold'); - expect(await firstValueFrom(license$)).not.toBe(customLicense); + expect(await license$.pipe(take(1)).toPromise()).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(); - plugin.setup(coreSetup); - const { createLicensePoller } = plugin.start(); + await plugin.setup(coreSetup); + const { createLicensePoller } = await plugin.start(); const customClient = createEsClient({ license: buildRawLicense({ type: 'gold' }), @@ -330,10 +344,24 @@ 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(); - plugin.setup(coreSetup); + await plugin.setup(coreSetup); expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(` Array [ @@ -347,10 +375,22 @@ 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(); - plugin.setup(coreSetup); + await plugin.setup(coreSetup); expect(coreSetup.http.registerOnPreResponse).toHaveBeenCalledTimes(1); }); @@ -359,9 +399,14 @@ 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(); - plugin.setup(coreSetup); - const { license$ } = plugin.start(); + await plugin.setup(coreSetup); + const { license$ } = await 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 b3ac583e7c81e..0d21cd689bf46 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -8,7 +8,12 @@ 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, @@ -16,17 +21,73 @@ import type { PluginInitializerContext, IClusterClient, } from '@kbn/core/server'; + import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider'; -import type { ILicense } from '../common/types'; +import type { + ILicense, + PublicLicense, + PublicFeatures, + LicenseType, + LicenseStatus, +} 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'; -import { getLicenseFetcher } from './license_fetcher'; + +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'); +} /** * @public @@ -92,16 +153,9 @@ export class LicensingPlugin implements Plugin + this.fetchLicense(clusterClient) ); this.loggingSubscription = license$.subscribe((license) => @@ -124,6 +178,50 @@ 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 fcccdecb66c00..83b39cb663715 100644 --- a/x-pack/plugins/licensing/server/types.ts +++ b/x-pack/plugins/licensing/server/types.ts @@ -14,8 +14,6 @@ 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 1deb735f99466..323f77b3b0ebc 100644 --- a/x-pack/plugins/licensing/tsconfig.json +++ b/x-pack/plugins/licensing/tsconfig.json @@ -15,8 +15,7 @@ "@kbn/i18n", "@kbn/analytics-client", "@kbn/subscription-tracking", - "@kbn/core-analytics-browser", - "@kbn/logging-mocks" + "@kbn/core-analytics-browser" ], "exclude": ["target/**/*"] }