diff --git a/src/core/utils/poller.test.ts b/src/core/utils/poller.test.ts deleted file mode 100644 index df89f7341c956..0000000000000 --- a/src/core/utils/poller.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Poller } from './poller'; - -const delay = (duration: number) => new Promise(r => setTimeout(r, duration)); - -// FLAKY: https://github.com/elastic/kibana/issues/44560 -describe.skip('Poller', () => { - let handler: jest.Mock; - let poller: Poller; - - beforeEach(() => { - handler = jest.fn().mockImplementation((iteration: number) => `polling-${iteration}`); - poller = new Poller(100, 'polling', handler); - }); - - afterEach(() => { - poller.unsubscribe(); - }); - - it('returns an observable of subject', async () => { - await delay(300); - expect(poller.subject$.getValue()).toBe('polling-2'); - }); - - it('executes a function on an interval', async () => { - await delay(300); - expect(handler).toBeCalledTimes(3); - }); - - it('no longer polls after unsubscribing', async () => { - await delay(300); - poller.unsubscribe(); - await delay(300); - expect(handler).toBeCalledTimes(3); - }); - - it('does not add next value if returns undefined', async () => { - const values: any[] = []; - const polling = new Poller(100, 'polling', iteration => { - if (iteration % 2 === 0) { - return `polling-${iteration}`; - } - }); - - polling.subject$.subscribe(value => { - values.push(value); - }); - await delay(300); - polling.unsubscribe(); - - expect(values).toEqual(['polling', 'polling-0', 'polling-2']); - }); -}); diff --git a/src/core/utils/poller.ts b/src/core/utils/poller.ts deleted file mode 100644 index 7c50db74bcefb..0000000000000 --- a/src/core/utils/poller.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { BehaviorSubject, timer } from 'rxjs'; - -/** - * Create an Observable BehaviorSubject to invoke a function on an interval - * which returns the next value for the observable. - * @public - */ -export class Poller { - /** - * The observable to observe for changes to the poller value. - */ - public readonly subject$ = new BehaviorSubject(this.initialValue); - private poller$ = timer(0, this.frequency); - private subscription = this.poller$.subscribe(async iteration => { - const next = await this.handler(iteration); - - if (next !== undefined) { - this.subject$.next(next); - } - - return iteration; - }); - - constructor( - private frequency: number, - private initialValue: T, - private handler: (iteration: number) => Promise | T | undefined - ) {} - - /** - * Permanently end the polling operation. - */ - unsubscribe() { - return this.subscription.unsubscribe(); - } -} diff --git a/x-pack/dev-tools/jest/setup/polyfills.js b/x-pack/dev-tools/jest/setup/polyfills.js index 8e5c5a8025b82..9292e3c0425af 100644 --- a/x-pack/dev-tools/jest/setup/polyfills.js +++ b/x-pack/dev-tools/jest/setup/polyfills.js @@ -16,3 +16,5 @@ Object.defineProperty(window, 'MutationObserver', { value: MutationObserver }); const URL = { createObjectURL: () => '' }; Object.defineProperty(window, 'URL', { value: URL }); + +require('jest-localstorage-mock'); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap index c3d4ff673e3d5..a266bf09fc5bf 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap @@ -11,7 +11,7 @@ exports[`LayerPanel is rendered 1`] = ` "get": [Function], "remove": [Function], "set": [Function], - "store": undefined, + "store": LocalStorage {}, }, } } diff --git a/x-pack/package.json b/x-pack/package.json index d2317abb86b02..77d4af82734af 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -149,6 +149,7 @@ "hapi": "^17.5.3", "jest": "^24.9.0", "jest-cli": "^24.9.0", + "jest-localstorage-mock": "^2.4.0", "jest-styled-components": "^6.3.3", "jsdom": "^12.2.0", "madge": "3.4.4", diff --git a/x-pack/plugins/licensing/server/constants.ts b/x-pack/plugins/licensing/common/constants.ts similarity index 66% rename from x-pack/plugins/licensing/server/constants.ts rename to x-pack/plugins/licensing/common/constants.ts index f2823ea00933c..4f733503b761f 100644 --- a/x-pack/plugins/licensing/server/constants.ts +++ b/x-pack/plugins/licensing/common/constants.ts @@ -4,9 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export const SERVICE_NAME = 'licensing'; +export const API_ROUTE = '/api/xpack/v1/info'; +export const LICENSING_SESSION = 'xpack.licensing'; +export const LICENSING_SESSION_SIGNATURE = 'xpack.licensing.signature'; +export const SIGNATURE_HEADER = 'kbn-xpack-sig'; export const DEFAULT_POLLING_FREQUENCY = 30001; // 30 seconds -export enum LICENSE_STATUS { +export enum LICENSE_CHECK_STATE { Unavailable = 'UNAVAILABLE', Invalid = 'INVALID', Expired = 'EXPIRED', diff --git a/x-pack/plugins/licensing/common/delay.ts b/x-pack/plugins/licensing/common/delay.ts new file mode 100644 index 0000000000000..f4cfe3bc93374 --- /dev/null +++ b/x-pack/plugins/licensing/common/delay.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Utility function to delay execution of the event loop for a specified duration. + * @param duration {number} Minimum amount of time in milliseconds to delay execution + */ +export const delay = (duration: number) => new Promise(r => setTimeout(r, duration)); diff --git a/x-pack/plugins/licensing/common/has_license_info_changed.test.ts b/x-pack/plugins/licensing/common/has_license_info_changed.test.ts new file mode 100644 index 0000000000000..35d27e95a1a3c --- /dev/null +++ b/x-pack/plugins/licensing/common/has_license_info_changed.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { License } from './license'; +import { hasLicenseInfoChanged } from './has_license_info_changed'; + +function license({ error, ...rawLicense }: { error?: Error; [key: string]: any } = {}) { + return new License({ + error, + license: Object.keys(rawLicense).length ? rawLicense : undefined, + }); +} + +// Each test should ensure that left-to-right and right-to-left comparisons are captured. + +describe('has license info changed', () => { + describe('undefined', () => { + test('undefined <-> undefined', async () => { + expect(hasLicenseInfoChanged(undefined, undefined)).toBe(false); + }); + + test('undefined <-> License', async () => { + expect(hasLicenseInfoChanged(undefined, license())).toBe(true); + expect(hasLicenseInfoChanged(license(), undefined)).toBe(true); + }); + }); + + describe('License', () => { + test('License <-> available License', async () => { + expect(hasLicenseInfoChanged(license(), license({ uid: 'alpha' }))).toBe(true); + expect(hasLicenseInfoChanged(license(), license({ uid: 'alpha' }))).toBe(true); + }); + + test('uid License <-> uid License', async () => { + expect(hasLicenseInfoChanged(license({ uid: 'alpha' }), license({ uid: 'alpha' }))).toBe( + false + ); + expect(hasLicenseInfoChanged(license({ uid: 'alpha' }), license({ uid: 'beta' }))).toBe( + false + ); + expect(hasLicenseInfoChanged(license({ uid: 'beta' }), license({ uid: 'alpha' }))).toBe( + false + ); + }); + + test('License <-> type License', async () => { + expect(hasLicenseInfoChanged(license({ type: 'basic' }), license())).toBe(true); + expect(hasLicenseInfoChanged(license(), license({ type: 'basic' }))).toBe(true); + }); + + test('type License <-> type License | mismatched type', async () => { + expect(hasLicenseInfoChanged(license({ type: 'basic' }), license({ type: 'gold' }))).toBe( + true + ); + expect(hasLicenseInfoChanged(license({ type: 'gold' }), license({ type: 'basic' }))).toBe( + true + ); + }); + + test('License <-> status License', async () => { + expect(hasLicenseInfoChanged(license({ status: 'active' }), license())).toBe(true); + expect(hasLicenseInfoChanged(license(), license({ status: 'active' }))).toBe(true); + }); + + test('status License <-> status License | mismatched status', async () => { + expect( + hasLicenseInfoChanged(license({ status: 'active' }), license({ status: 'inactive' })) + ).toBe(true); + expect( + hasLicenseInfoChanged(license({ status: 'inactive' }), license({ status: 'active' })) + ).toBe(true); + }); + + test('License <-> expiry License', async () => { + expect(hasLicenseInfoChanged(license({ expiry_date_in_millis: 100 }), license())).toBe(true); + expect(hasLicenseInfoChanged(license(), license({ expiry_date_in_millis: 100 }))).toBe(true); + }); + + test('expiry License <-> expiry License | mismatched expiry', async () => { + expect( + hasLicenseInfoChanged( + license({ expiry_date_in_millis: 100 }), + license({ expiry_date_in_millis: 200 }) + ) + ).toBe(true); + expect( + hasLicenseInfoChanged( + license({ expiry_date_in_millis: 200 }), + license({ expiry_date_in_millis: 100 }) + ) + ).toBe(true); + }); + }); + + describe('error License', () => { + test('License <-> error License', async () => { + expect(hasLicenseInfoChanged(license({ error: new Error('alpha') }), license())).toBe(true); + expect(hasLicenseInfoChanged(license(), license({ error: new Error('alpha') }))).toBe(true); + }); + + test('error License <-> error License | matched messages', async () => { + expect( + hasLicenseInfoChanged( + license({ error: new Error('alpha') }), + license({ error: new Error('alpha') }) + ) + ).toBe(false); + }); + + test('error License <-> error License | mismatched messages', async () => { + expect( + hasLicenseInfoChanged( + license({ error: new Error('alpha') }), + license({ error: new Error('beta') }) + ) + ).toBe(true); + expect( + hasLicenseInfoChanged( + license({ error: new Error('beta') }), + license({ error: new Error('alpha') }) + ) + ).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/licensing/common/has_license_info_changed.ts b/x-pack/plugins/licensing/common/has_license_info_changed.ts new file mode 100644 index 0000000000000..0b965e4a6654c --- /dev/null +++ b/x-pack/plugins/licensing/common/has_license_info_changed.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { License } from './license'; + +/** + * @public + * Check if 2 potential license instances have changes between them + */ +export function hasLicenseInfoChanged( + currentLicense: License | undefined, + newLicense: License | undefined +) { + // If we have 2 valid license instances, let's check: + // 1. That if they both contain an error that the message has changed + // 2. Check if the type has changed + // 3. Check if the status has changed + // 4. Check if the expiration date has changed + // 5. Check is the availability of the license has changed. + if (currentLicense && newLicense) { + if ( + (currentLicense.error && !newLicense.error) || + (!currentLicense.error && newLicense.error) || + (newLicense.error && + currentLicense.error && + newLicense.error.message !== currentLicense.error.message) + ) { + return true; + } + + return ( + newLicense.type !== currentLicense.type || + newLicense.status !== currentLicense.status || + newLicense.expiryDateInMillis !== currentLicense.expiryDateInMillis || + newLicense.isAvailable !== currentLicense.isAvailable + ); + } + + // If we have made it here, one or both of the licenses are undefined. + // If they match (both undefined), nothing has changed, otherwise it did. + return currentLicense !== newLicense; +} diff --git a/x-pack/plugins/licensing/common/license.ts b/x-pack/plugins/licensing/common/license.ts new file mode 100644 index 0000000000000..0d6ee239c8343 --- /dev/null +++ b/x-pack/plugins/licensing/common/license.ts @@ -0,0 +1,290 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { LicenseFeature } from './license_feature'; +import { LICENSE_CHECK_STATE, LICENSE_TYPE } from './constants'; +import { LicenseType, ILicense, IObjectifiedLicense, IRawLicense, IRawFeatures } from './types'; + +function toLicenseType(minimumLicenseRequired: LICENSE_TYPE | string) { + if (typeof minimumLicenseRequired !== 'string') { + return minimumLicenseRequired; + } + + if (!(minimumLicenseRequired in LICENSE_TYPE)) { + throw new Error(`${minimumLicenseRequired} is not a valid license type`); + } + + return LICENSE_TYPE[minimumLicenseRequired as LicenseType]; +} + +interface LicenseArgs { + license?: IRawLicense; + features?: IRawFeatures; + error?: Error; + clusterSource?: string; +} + +/** + * @public + */ +export class License implements ILicense { + /** + * Determine if the license is defined/contains data. + */ + private readonly hasLicense: boolean; + + /** + * The raw license information. + */ + private readonly license: IRawLicense; + + /** + * The raw feature information. + */ + private readonly features: IRawFeatures; + + /** + * A cached copy of the objectified license. + */ + private objectified!: any; + + /** + * Mapping of feature names to feature information. + */ + private readonly featuresMap: Map; + + /** + * Optional cluster source for providing supplemental informational reasons. Server-only. + */ + private clusterSource?: string; + + /** + * A potential error denoting the failure of the license from being retrieved. + */ + public error?: Error; + + /** + * Generate a License instance from a previously-objectified license. + * @param objectified An objectified license instance, typically generated from a license's toObject() method. + * @param licenseArgs Additional properties to specify for the creation of a License instance. + */ + static fromObjectified( + objectified: IObjectifiedLicense, + { error, clusterSource }: LicenseArgs = {} + ) { + const license = { + uid: objectified.license.uid, + status: objectified.license.status, + type: objectified.license.type, + expiry_date_in_millis: objectified.license.expiryDateInMillis, + }; + const features = objectified.features.reduce( + (map, feature) => ({ + ...map, + [feature.name]: { + available: feature.isAvailable, + enabled: feature.isEnabled, + }, + }), + {} + ); + + return new License({ + error, + clusterSource, + license, + features, + }); + } + + constructor({ license, features, error, clusterSource }: LicenseArgs) { + this.hasLicense = Boolean(license); + this.license = license || {}; + this.features = features || {}; + this.featuresMap = new Map(); + this.error = error; + this.clusterSource = clusterSource; + } + + /** + * UID for license. + */ + public get uid() { + return this.license.uid; + } + + /** + * The validity status of the license. + */ + public get status() { + return this.license.status; + } + + /** + * Determine if the status of the license is active. + */ + public get isActive() { + return this.status === 'active'; + } + + /** + * Unix epoch of the expiration date of the license. + */ + public get expiryDateInMillis() { + return this.license.expiry_date_in_millis; + } + + /** + * The license type, being usually one of basic, standard, gold, platinum, or trial. + */ + public get type() { + return this.license.type; + } + + /** + * Determine if the license container has information. + */ + public get isAvailable() { + return this.hasLicense; + } + + /** + * Determine if the type of the license is basic, and also active. + */ + public get isBasic() { + return this.isActive && this.type === 'basic'; + } + + /** + * Determine if the type of the license is not basic, and also active. + */ + public get isNotBasic() { + return this.isActive && this.type !== 'basic'; + } + + /** + * If the license is not available, provides a string or Error containing the reason. + */ + public get reasonUnavailable() { + if (!this.isAvailable) { + return `[${this.clusterSource}] Elasticsearch cluster did not respond with license information.`; + } + + if (this.error instanceof Error && (this.error as any).status === 400) { + return `X-Pack plugin is not installed on the [${this.clusterSource}] Elasticsearch cluster.`; + } + + return this.error; + } + + /** + * Determine if the provided license types match against the license type. + * @param candidateLicenses license types to intersect against the license. + */ + isOneOf(candidateLicenses: string | string[]) { + if (!Array.isArray(candidateLicenses)) { + candidateLicenses = [candidateLicenses]; + } + + if (!this.type) { + return false; + } + + return candidateLicenses.includes(this.type); + } + + /** + * Determine if the provided license type is sufficient for the current license. + * @param minimum a license type to determine for sufficiency + */ + meetsMinimumOf(minimum: LICENSE_TYPE) { + return LICENSE_TYPE[this.type as LicenseType] >= minimum; + } + + /** + * For a given plugin and license type, receive information about the status of the license. + * @param pluginName the name of the plugin + * @param minimumLicenseRequired the minimum valid license for operating the given plugin + */ + check(pluginName: string, minimumLicenseRequired: LICENSE_TYPE | string) { + const minimum = toLicenseType(minimumLicenseRequired); + + if (!this.isAvailable) { + return { + state: LICENSE_CHECK_STATE.Unavailable, + message: i18n.translate('xpack.licensing.check.errorUnavailableMessage', { + defaultMessage: + 'You cannot use {pluginName} because license information is not available at this time.', + values: { pluginName }, + }), + }; + } + + const { type: licenseType } = this.license; + + if (!this.meetsMinimumOf(minimum)) { + return { + state: LICENSE_CHECK_STATE.Invalid, + message: i18n.translate('xpack.licensing.check.errorUnsupportedMessage', { + defaultMessage: + 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', + values: { licenseType, pluginName }, + }), + }; + } + + if (!this.isActive) { + return { + state: LICENSE_CHECK_STATE.Expired, + message: i18n.translate('xpack.licensing.check.errorExpiredMessage', { + defaultMessage: + 'You cannot use {pluginName} because your {licenseType} license has expired.', + values: { licenseType, pluginName }, + }), + }; + } + + return { state: LICENSE_CHECK_STATE.Valid }; + } + + /** + * Receive a serialized plain object of the license. + */ + toObject(): IObjectifiedLicense { + if (this.objectified) { + return this.objectified; + } + + this.objectified = { + license: { + type: this.type, + status: this.status, + uid: this.uid, + isActive: this.isActive, + expiryDateInMillis: this.expiryDateInMillis, + }, + features: [...this.featuresMap].map(([, feature]) => feature.toObject()), + }; + + return this.objectified; + } + + /** + * A specific API for interacting with the specific features of the license. + * @param name the name of the feature to interact with + */ + getFeature(name: string) { + let feature = this.featuresMap.get(name); + + if (!feature) { + feature = new LicenseFeature(name, this.features[name]); + this.featuresMap.set(name, feature); + } + + return feature; + } +} diff --git a/x-pack/plugins/licensing/common/license_feature.ts b/x-pack/plugins/licensing/common/license_feature.ts new file mode 100644 index 0000000000000..fb84e9402b2f5 --- /dev/null +++ b/x-pack/plugins/licensing/common/license_feature.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRawFeature } from './types'; + +const empty = { available: false, enabled: false }; + +/** + * @public + */ +export class LicenseFeature { + constructor(public name: string, private feature: IRawFeature = empty) {} + + /** + * Determine if the feature is available. + */ + public get isAvailable() { + return this.feature.available; + } + + /** + * Determine if the feature is enabled. + */ + public get isEnabled() { + return this.feature.enabled; + } + + /** + * Create an object suitable for feature serialization. + */ + public toObject() { + return { + name: this.name, + isAvailable: this.isAvailable, + isEnabled: this.isEnabled, + }; + } +} diff --git a/x-pack/plugins/licensing/common/license_merge.ts b/x-pack/plugins/licensing/common/license_merge.ts new file mode 100644 index 0000000000000..b1b3c3de1bb9c --- /dev/null +++ b/x-pack/plugins/licensing/common/license_merge.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { merge } from 'lodash'; + +export function licenseMerge(xpackInfo: any = {}) { + const rawLicense = merge( + { + license: { + uid: '00000000-0000-0000-0000-000000000000', + type: 'basic', + mode: 'basic', + status: 'active', + }, + features: { + ccr: { + available: false, + enabled: true, + }, + data_frame: { + available: true, + enabled: true, + }, + graph: { + available: false, + enabled: true, + }, + ilm: { + available: true, + enabled: true, + }, + logstash: { + available: false, + enabled: true, + }, + ml: { + available: false, + enabled: true, + }, + monitoring: { + available: true, + enabled: true, + }, + rollup: { + available: true, + enabled: true, + }, + security: { + available: true, + enabled: true, + }, + sql: { + available: true, + enabled: true, + }, + vectors: { + available: true, + enabled: true, + }, + voting_only: { + available: true, + enabled: true, + }, + watcher: { + available: false, + enabled: true, + }, + }, + }, + xpackInfo + ); + + if (xpackInfo.license === null) { + Reflect.deleteProperty(rawLicense, 'license'); + } + + return rawLicense; +} diff --git a/x-pack/plugins/licensing/server/types.ts b/x-pack/plugins/licensing/common/types.ts similarity index 68% rename from x-pack/plugins/licensing/server/types.ts rename to x-pack/plugins/licensing/common/types.ts index 27d3502b44779..f962e5b974401 100644 --- a/x-pack/plugins/licensing/server/types.ts +++ b/x-pack/plugins/licensing/common/types.ts @@ -5,11 +5,68 @@ */ import { Observable } from 'rxjs'; -import { TypeOf } from '@kbn/config-schema'; -import { schema } from './schema'; -import { LICENSE_TYPE, LICENSE_STATUS } from './constants'; +import { LICENSE_TYPE, LICENSE_CHECK_STATE } from './constants'; import { LicenseFeature } from './license_feature'; +/** + * @public + * Results from remote request fetching a raw license. + */ +export interface IRawLicense { + /** + * UID for license. + */ + uid?: string; + + /** + * The validity status of the license. + */ + status?: string; + + /** + * Unix epoch of the expiration date of the license. + */ + expiry_date_in_millis?: number; + + /** + * The license type, being usually one of basic, standard, gold, platinum, or trial. + */ + type?: LicenseType; +} + +/** + * @public + * Result from remote request fetching raw featureset. + */ +export interface IRawFeature { + available: boolean; + enabled: boolean; +} + +/** + * @public + * Results from remote request fetching raw featuresets. + */ +export interface IRawFeatures { + [key: string]: IRawFeature; +} + +/** @public */ +export interface IObjectifiedLicense { + license: { + uid: string; + type: LicenseType; + status: string; + isActive: boolean; + expiryDateInMillis: number; + }; + features: Array<{ + name: string; + isAvailable: boolean; + isEnabled: boolean; + }>; +} + /** * @public * Results from checking if a particular license type meets the minimum @@ -17,9 +74,9 @@ import { LicenseFeature } from './license_feature'; */ export interface ILicenseCheck { /** - * The status of checking the results of a license type meeting the license minimum. + * The state of checking the results of a license type meeting the license minimum. */ - check: LICENSE_STATUS; + state: LICENSE_CHECK_STATE; /** * A message containing the reason for a license type not being valid. */ @@ -50,7 +107,7 @@ export interface ILicense { /** * The license type, being usually one of basic, standard, gold, platinum, or trial. */ - type?: string; + type?: LicenseType; /** * Determine if the license container has information. @@ -70,12 +127,7 @@ export interface ILicense { /** * If the license is not available, provides a string or Error containing the reason. */ - reasonUnavailable: string | Error | null; - - /** - * The MD5 hash of the serialized license. - */ - signature: string; + reasonUnavailable?: string | Error; /** * Determine if the provided license types match against the license type. @@ -99,25 +151,22 @@ export interface ILicense { /** * Receive a serialized plain object of the license. */ - toObject(): any; + toObject(): IObjectifiedLicense; /** * A specific API for interacting with the specific features of the license. * @param name the name of the feature to interact with */ - getFeature(name: string): LicenseFeature | undefined; + getFeature(name: string): LicenseFeature; } /** @public */ export interface LicensingPluginSetup { license$: Observable; + refresh(): void; } /** @public */ -export type LicensingConfigType = TypeOf; -/** @public */ export type LicenseType = keyof typeof LICENSE_TYPE; -/** @public */ -export type LicenseFeatureSerializer = (licensing: ILicense) => any; /** @public */ export interface LicensingRequestContext { diff --git a/x-pack/plugins/licensing/kibana.json b/x-pack/plugins/licensing/kibana.json index a76ce1ef6a23c..9edaa726c6ba9 100644 --- a/x-pack/plugins/licensing/kibana.json +++ b/x-pack/plugins/licensing/kibana.json @@ -2,7 +2,7 @@ "id": "licensing", "version": "0.0.1", "kibanaVersion": "kibana", - "configPath": ["x-pack", "licensing"], + "configPath": ["xpack", "licensing"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/licensing/public/__fixtures__/setup.ts b/x-pack/plugins/licensing/public/__fixtures__/setup.ts new file mode 100644 index 0000000000000..51fb08380dbc4 --- /dev/null +++ b/x-pack/plugins/licensing/public/__fixtures__/setup.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { take, skip } from 'rxjs/operators'; +import { CoreSetup } from '../../../../../src/core/public'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { licenseMerge } from '../../common/license_merge'; +import { Plugin } from '../plugin'; + +export function setupOnly() { + const coreSetup = coreMock.createSetup(); + const plugin = new Plugin(coreMock.createPluginInitializerContext()); + + return { coreSetup, plugin }; +} + +export async function setup(xpackInfo = {}, shouldSkip = true) { + const { coreSetup, plugin } = setupOnly(); + + coreSetup.http.get.mockResolvedValue(licenseMerge(xpackInfo)); + + const licensingSetup = await plugin.setup(coreSetup); + const license = await (shouldSkip + ? licensingSetup.license$ + .pipe( + skip(1), + take(1) + ) + .toPromise() + : licensingSetup.license$.pipe(take(1)).toPromise()); + + return Object.assign(licensingSetup, { + coreSetup, + plugin, + license, + }); +} + +// NOTE: Since we don't have a real interceptor here due to mocks, +// we fake the process and necessary objects. +export function mockHttpInterception( + coreSetup: MockedKeys, + next?: (path: string, headers: Map) => void +): Promise> { + return new Promise(resolve => { + coreSetup.http.intercept.mockImplementation(interceptor => { + coreSetup.http.get.mockImplementation(path => { + const headers = new Map(); + + if (next) { + next(path, headers); + } + + (interceptor.response as (_: any) => void)({ + request: { url: path }, + response: { headers }, + }); + + return licenseMerge({}); + }); + + const spy = jest.fn(); + + resolve(spy); + + return spy; + }); + }); +} diff --git a/x-pack/plugins/licensing/public/index.ts b/x-pack/plugins/licensing/public/index.ts new file mode 100644 index 0000000000000..3213c4d7a353d --- /dev/null +++ b/x-pack/plugins/licensing/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { Plugin } from './plugin'; + +export * from '../common/types'; +export * from '../common/constants'; +export const plugin = (context: PluginInitializerContext) => new Plugin(context); diff --git a/x-pack/plugins/licensing/public/license.test.ts b/x-pack/plugins/licensing/public/license.test.ts new file mode 100644 index 0000000000000..e27e5fc6ecfc1 --- /dev/null +++ b/x-pack/plugins/licensing/public/license.test.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILicense } from '../common/types'; +import { LICENSE_CHECK_STATE } from '../common/constants'; +import { LicenseFeature } from '../common/license_feature'; +import { setup } from './__fixtures__/setup'; +import { Plugin } from './plugin'; + +describe('license', () => { + let plugin: Plugin; + let license: ILicense; + + afterEach(async () => { + if (plugin) { + await plugin.stop(); + } + sessionStorage.clear(); + }); + + test('uid returns a UID field', async () => { + ({ plugin, license } = await setup()); + + expect(license.uid).toBe('00000000-0000-0000-0000-000000000000'); + }); + + test('isActive returns true if status is active', async () => { + ({ plugin, license } = await setup()); + + expect(license.isActive).toBe(true); + }); + + test('isActive returns false if status is not active', async () => { + ({ plugin, license } = await setup({ + license: { + status: 'aCtIvE', // needs to match exactly + }, + })); + + expect(license.isActive).toBe(false); + }); + + test('expiryDateInMillis returns expiry_date_in_millis', async () => { + const expiry = Date.now(); + + ({ plugin, license } = await setup({ + license: { + expiry_date_in_millis: expiry, + }, + })); + + expect(license.expiryDateInMillis).toBe(expiry); + }); + + test('isOneOf returns true if the type includes one of the license types', async () => { + ({ plugin, license } = await setup({ + license: { + type: 'platinum', + }, + })); + + expect(license.isOneOf('platinum')).toBe(true); + expect(license.isOneOf(['platinum'])).toBe(true); + expect(license.isOneOf(['gold', 'platinum'])).toBe(true); + expect(license.isOneOf(['platinum', 'gold'])).toBe(true); + expect(license.isOneOf(['basic', 'gold'])).toBe(false); + expect(license.isOneOf(['basic'])).toBe(false); + expect(license.isOneOf('alpha')).toBe(false); + expect(license.isOneOf(['alpha', 'beta'])).toBe(false); + }); + + test('type returns the license type', async () => { + ({ plugin, license } = await setup()); + + expect(license.type).toBe('basic'); + }); + + test('returns feature API with getFeature', async () => { + ({ plugin, license } = await setup()); + + const security = license.getFeature('security'); + const fake = license.getFeature('fake'); + + expect(security).toBeInstanceOf(LicenseFeature); + expect(fake).toBeInstanceOf(LicenseFeature); + }); + + describe('isActive', () => { + test('should return Valid if active and check matches', async () => { + ({ plugin, license } = await setup({ + license: { + type: 'gold', + }, + })); + + expect(license.check('test', 'basic').state).toBe(LICENSE_CHECK_STATE.Valid); + expect(license.check('test', 'gold').state).toBe(LICENSE_CHECK_STATE.Valid); + }); + + test('should return Invalid if active and check does not match', async () => { + ({ plugin, license } = await setup()); + + const { state } = license.check('test', 'gold'); + + expect(state).toBe(LICENSE_CHECK_STATE.Invalid); + }); + + test('should return Unavailable if missing license', async () => { + ({ plugin, license } = await setup({ license: null }, false)); + + const { state } = license.check('test', 'gold'); + + expect(state).toBe(LICENSE_CHECK_STATE.Unavailable); + }); + + test('should return Expired if not active', async () => { + ({ plugin, license } = await setup({ + license: { + status: 'not-active', + }, + })); + + const { state } = license.check('test', 'basic'); + + expect(state).toBe(LICENSE_CHECK_STATE.Expired); + }); + }); + + describe('basic', () => { + test('isBasic is true if active and basic', async () => { + ({ plugin, license } = await setup()); + + expect(license.isBasic).toBe(true); + }); + + test('isBasic is false if active and not basic', async () => { + ({ plugin, license } = await setup({ + license: { + type: 'gold', + }, + })); + + expect(license.isBasic).toBe(false); + }); + + test('isBasic is false if not active and basic', async () => { + ({ plugin, license } = await setup({ + license: { + status: 'not-active', + }, + })); + + expect(license.isBasic).toBe(false); + }); + + test('isNotBasic is false if not active', async () => { + ({ plugin, license } = await setup({ + license: { + status: 'not-active', + }, + })); + + expect(license.isNotBasic).toBe(false); + }); + + test('isNotBasic is true if active and not basic', async () => { + ({ plugin, license } = await setup({ + license: { + type: 'gold', + }, + })); + + expect(license.isNotBasic).toBe(true); + }); + + test('isNotBasic is false if active and basic', async () => { + ({ plugin, license } = await setup()); + + expect(license.isNotBasic).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/licensing/public/license_feature.test.ts b/x-pack/plugins/licensing/public/license_feature.test.ts new file mode 100644 index 0000000000000..201a8fc502c85 --- /dev/null +++ b/x-pack/plugins/licensing/public/license_feature.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILicense } from '../common/types'; +import { setup } from './__fixtures__/setup'; +import { Plugin } from './plugin'; + +describe('licensing feature', () => { + let plugin: Plugin; + let license: ILicense; + + afterEach(async () => { + await plugin.stop(); + sessionStorage.clear(); + }); + + describe('valid feature', () => { + test('isAvailable', async () => { + ({ plugin, license } = await setup()); + + const security = license.getFeature('security'); + + expect(security.isAvailable).toBe(true); + }); + + test('isEnabled', async () => { + ({ plugin, license } = await setup()); + + const security = license.getFeature('security'); + + expect(security!.isEnabled).toBe(true); + }); + + test('name', async () => { + ({ plugin, license } = await setup()); + + const security = license.getFeature('security'); + + expect(security!.name).toBe('security'); + }); + }); + + describe('invalid feature', () => { + test('isAvailable', async () => { + ({ plugin, license } = await setup()); + + const fake = license.getFeature('fake'); + + expect(fake.isAvailable).toBe(false); + }); + + test('isEnabled', async () => { + ({ plugin, license } = await setup()); + + const fake = license.getFeature('fake'); + + expect(fake.isEnabled).toBe(false); + }); + + test('name', async () => { + ({ plugin, license } = await setup()); + + const fake = license.getFeature('fake'); + + expect(fake.name).toBe('fake'); + }); + }); +}); diff --git a/x-pack/plugins/licensing/public/plugin.test.ts b/x-pack/plugins/licensing/public/plugin.test.ts new file mode 100644 index 0000000000000..7e2cd9df46767 --- /dev/null +++ b/x-pack/plugins/licensing/public/plugin.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { take, skip } from 'rxjs/operators'; +import { ILicense } from '../common/types'; +import { License } from '../common/license'; +import { + API_ROUTE, + LICENSING_SESSION, + LICENSING_SESSION_SIGNATURE, + SIGNATURE_HEADER, +} from '../common/constants'; +import { delay } from '../common/delay'; +import { Plugin } from './plugin'; +import { mockHttpInterception, setup, setupOnly } from './__fixtures__/setup'; + +describe('licensing plugin', () => { + let plugin: Plugin; + let license: ILicense; + + afterEach(async () => { + await plugin.stop(); + sessionStorage.clear(); + }); + + test('returns instance of licensing setup', async () => { + ({ plugin, license } = await setup()); + expect(license).toBeInstanceOf(License); + }); + + test('intercepted HTTP request causes a refresh if the signature changes', async () => { + const { coreSetup, plugin: _plugin } = await setupOnly(); + + plugin = _plugin; + mockHttpInterception(coreSetup, (path, headers) => { + // Here we emulate that the server returned a new signature header, + // which should trigger the plugin to refresh(). + if (path.includes('/fake')) { + headers.set(SIGNATURE_HEADER, 'fake-signature'); + } + }); + + const licensingSetup = await plugin.setup(coreSetup); + + await licensingSetup.license$ + .pipe( + skip(1), + take(1) + ) + .toPromise(); + + const refresh = jest.spyOn(licensingSetup, 'refresh'); + + await coreSetup.http.get('/api/fake'); + + expect(refresh).toHaveBeenCalled(); + }); + + test('calling refresh triggers fetch', async () => { + const { coreSetup, plugin: _plugin, license$, refresh } = await setup(); + + // We create a dummy subscription to ensure that calls to refresh actually + // get triggered in the observable. + license$.subscribe(() => {}); + plugin = _plugin; + coreSetup.http.get.mockClear(); + refresh(); + await delay(200); + + expect(coreSetup.http.get).toHaveBeenCalledWith(API_ROUTE); + }); + + test('still returns instance of licensing setup when request fails', async () => { + const { coreSetup, plugin: _plugin } = await setupOnly(); + + plugin = _plugin; + coreSetup.http.get.mockRejectedValue(new Error('test')); + + const { license$ } = await plugin.setup(coreSetup); + const finalLicense = await license$ + .pipe( + skip(1), + take(1) + ) + .toPromise(); + + expect(finalLicense).toBeInstanceOf(License); + }); +}); + +describe('licensing plugin | stopping', () => { + afterEach(() => { + sessionStorage.clear(); + }); + + test('stopping plugin removes HTTP interceptor', async () => { + const { coreSetup, plugin } = await setupOnly(); + let removeInterceptor: jest.Mock; + + mockHttpInterception(coreSetup).then(spy => { + removeInterceptor = spy; + }); + + await plugin.setup(coreSetup); + await plugin.stop(); + + expect(removeInterceptor!).toHaveBeenCalled(); + }); +}); + +describe('licensing plugin | session storage', () => { + let plugin: Plugin; + let license$: Observable; + + afterEach(async () => { + sessionStorage.clear(); + await plugin.stop(); + }); + + test('loads licensing session', async () => { + sessionStorage.setItem( + LICENSING_SESSION, + JSON.stringify({ + license: { + uid: 'alpha', + }, + features: [ + { + name: 'fake', + isAvailable: true, + isEnabled: true, + }, + ], + }) + ); + ({ plugin, license$ } = await setup()); + + const initial = await license$.pipe(take(1)).toPromise(); + + expect(initial.uid).toBe('alpha'); + expect(initial.getFeature('fake').isAvailable).toBe(true); + }); + + test('loads signature session', async () => { + const FAKE_SIGNATURE = 'fake-signature'; + + sessionStorage.setItem(LICENSING_SESSION_SIGNATURE, FAKE_SIGNATURE); + + const { coreSetup, plugin: _plugin } = await setupOnly(); + + plugin = _plugin; + mockHttpInterception(coreSetup, (path, headers) => { + // Here we set the header signature during interception to be the same value as what + // we stored in sessionStorage. Below when we make requests to the fake API, + // if these values match, the plugin's refresh() method should not be called. + headers.set(SIGNATURE_HEADER, FAKE_SIGNATURE); + }); + + const licensingSetup = await plugin.setup(coreSetup); + + await licensingSetup.license$ + .pipe( + skip(1), + take(1) + ) + .toPromise(); + + const refresh = jest.spyOn(licensingSetup, 'refresh'); + + await coreSetup.http.get('/api/fake'); + + expect(refresh).toHaveBeenCalledTimes(0); + }); + + test('session is cleared when request fails', async () => { + const { coreSetup, plugin: _plugin } = await setupOnly(); + + plugin = _plugin; + coreSetup.http.get.mockRejectedValue(new Error('test')); + ({ license$ } = await plugin.setup(coreSetup)); + + await license$ + .pipe( + skip(1), + take(1) + ) + .toPromise(); + + expect(sessionStorage.removeItem).toHaveBeenCalledWith(LICENSING_SESSION); + expect(sessionStorage.removeItem).toHaveBeenCalledWith(LICENSING_SESSION_SIGNATURE); + }); +}); diff --git a/x-pack/plugins/licensing/public/plugin.ts b/x-pack/plugins/licensing/public/plugin.ts new file mode 100644 index 0000000000000..844b6decbf8e3 --- /dev/null +++ b/x-pack/plugins/licensing/public/plugin.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject, Subject, merge, of } from 'rxjs'; +import { filter, map, pairwise, switchMap, takeUntil, tap } from 'rxjs/operators'; +import { + CoreSetup, + CoreStart, + Plugin as CorePlugin, + PluginInitializerContext, +} from 'src/core/public'; +import { LicensingPluginSetup, IObjectifiedLicense } from '../common/types'; +import { + API_ROUTE, + LICENSING_SESSION, + LICENSING_SESSION_SIGNATURE, + SIGNATURE_HEADER, +} from '../common/constants'; +import { License } from '../common/license'; +import { hasLicenseInfoChanged } from '../common/has_license_info_changed'; + +/** + * @public + * A plugin for fetching, refreshing, and receiving information about the license for the + * current Kibana instance. + */ +export class Plugin implements CorePlugin { + /** + * Used as a flag to halt all other plugin observables. + */ + private stop$ = new Subject(); + + /** + * A function to execute once the plugin's HTTP interceptor needs to stop listening. + */ + private removeInterceptor!: () => void; + + /** + * Used to trigger manual fetches of the license information from the server. + */ + private refresher$ = new BehaviorSubject(true); + + constructor(context: PluginInitializerContext) {} + + /** + * Fetch the objectified license and signature from session storage. + */ + private getSession(): { objectified?: IObjectifiedLicense; signature: string } { + const raw = sessionStorage.getItem(LICENSING_SESSION); + const signature = sessionStorage.getItem(LICENSING_SESSION_SIGNATURE) || ''; + const objectified = raw && JSON.parse(raw); + + return { objectified, signature }; + } + + /** + * Store the given license and signature in session storage. + */ + private setSession = (license: License, signature: string) => { + sessionStorage.setItem(LICENSING_SESSION, JSON.stringify(license.toObject())); + + if (signature) { + sessionStorage.setItem(LICENSING_SESSION_SIGNATURE, signature); + } + }; + + /** + * Clear license and signature information from session storage. + */ + private clearSession() { + sessionStorage.removeItem(LICENSING_SESSION); + sessionStorage.removeItem(LICENSING_SESSION_SIGNATURE); + } + + /** + * Initialize the plugin for consumption. + * @param core + */ + public setup(core: CoreSetup) { + const session = this.getSession(); + const initial$ = of( + session.objectified + ? License.fromObjectified(session.objectified) + : new License({ features: {} }) + ); + const setup = { + refresh: () => this.refresher$.next(true), + license$: initial$, + }; + + this.removeInterceptor = core.http.intercept({ + response: httpResponse => { + const signatureHeader = + (httpResponse.response && httpResponse.response.headers.get(SIGNATURE_HEADER)) || ''; + + if (signatureHeader !== session.signature) { + session.signature = signatureHeader; + + if (httpResponse.request && !httpResponse.request.url.includes(API_ROUTE)) { + setup.refresh(); + } + } + }, + }); + + // The license fetches occur in a defer/repeatWhen pair to avoid race conditions between refreshes and timers + const licenseFetches$ = this.refresher$.pipe( + takeUntil(this.stop$), + switchMap(async () => { + try { + const response = await core.http.get(API_ROUTE); + const rawLicense = response && response.license; + const features = (response && response.features) || {}; + + return new License({ license: rawLicense, features }); + } catch (err) { + // Prevent reusing stale license if the fetch operation fails + this.clearSession(); + return new License({ features: {}, error: err }); + } + }) + ); + const updates$ = merge(initial$, licenseFetches$).pipe( + takeUntil(this.stop$), + pairwise(), + filter(([previous, next]) => hasLicenseInfoChanged(previous, next)), + map(([, next]) => next) + ); + + setup.license$ = merge(initial$, updates$).pipe( + takeUntil(this.stop$), + tap(license => { + this.setSession(license, session.signature); + }) + ); + + return setup; + } + + public async start(core: CoreStart) {} + + /** + * Halt the plugin's operations and observables. + */ + public stop() { + this.stop$.next(); + this.stop$.complete(); + + if (this.removeInterceptor) { + this.removeInterceptor(); + } + } +} diff --git a/x-pack/plugins/licensing/server/__fixtures__/setup.ts b/x-pack/plugins/licensing/server/__fixtures__/setup.ts index a0cb1ea1a2b67..0cb0448641fc4 100644 --- a/x-pack/plugins/licensing/server/__fixtures__/setup.ts +++ b/x-pack/plugins/licensing/server/__fixtures__/setup.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { take, skip } from 'rxjs/operators'; +import { first, take, skip } from 'rxjs/operators'; import { merge } from 'lodash'; import { ClusterClient } from 'src/core/server'; import { coreMock } from '../../../../../src/core/server/mocks'; @@ -82,7 +82,7 @@ export async function licenseMerge(xpackInfo = {}) { export async function setupOnly(pluginInitializerContext: any = {}) { const coreSetup = coreMock.createSetup(); const clusterClient = ((await coreSetup.elasticsearch.dataClient$ - .pipe(take(1)) + .pipe(first()) .toPromise()) as unknown) as jest.Mocked>; const plugin = new Plugin( coreMock.createPluginInitializerContext({ @@ -93,23 +93,25 @@ export async function setupOnly(pluginInitializerContext: any = {}) { return { coreSetup, plugin, clusterClient }; } -export async function setup(xpackInfo = {}, pluginInitializerContext: any = {}) { +export async function setup(xpackInfo = {}, pluginInitializerContext: any = {}, shouldSkip = true) { const { coreSetup, clusterClient, plugin } = await setupOnly(pluginInitializerContext); clusterClient.callAsInternalUser.mockResolvedValueOnce(licenseMerge(xpackInfo)); - const { license$ } = await plugin.setup(coreSetup); - const license = await license$ - .pipe( - skip(1), - take(1) - ) - .toPromise(); + const licensingSetup = await plugin.setup(coreSetup); + const license = await (shouldSkip + ? licensingSetup.license$ + .pipe( + skip(1), + take(1) + ) + .toPromise() + : licensingSetup.license$.pipe(take(1)).toPromise()); - return { + return Object.assign(licensingSetup, { + coreSetup, plugin, - license$, license, clusterClient, - }; + }); } diff --git a/x-pack/plugins/licensing/server/index.ts b/x-pack/plugins/licensing/server/index.ts index 49415b63bc3b7..e179b0801989b 100644 --- a/x-pack/plugins/licensing/server/index.ts +++ b/x-pack/plugins/licensing/server/index.ts @@ -8,6 +8,7 @@ import { PluginInitializerContext } from 'src/core/server'; import { schema } from './schema'; import { Plugin } from './plugin'; -export * from './types'; +export * from '../common/types'; +export * from '../common/constants'; export const config = { schema }; export const plugin = (context: PluginInitializerContext) => new Plugin(context); diff --git a/x-pack/plugins/licensing/server/license.test.ts b/x-pack/plugins/licensing/server/license.test.ts index 1c308a6280449..519afabbfbe52 100644 --- a/x-pack/plugins/licensing/server/license.test.ts +++ b/x-pack/plugins/licensing/server/license.test.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILicense } from './types'; +import { ILicense } from '../common/types'; +import { LICENSE_CHECK_STATE } from '../common/constants'; +import { LicenseFeature } from '../common/license_feature'; import { Plugin } from './plugin'; -import { LICENSE_STATUS } from './constants'; -import { LicenseFeature } from './license_feature'; import { setup } from './__fixtures__/setup'; describe('license', () => { @@ -91,24 +91,24 @@ describe('license', () => { }, })); - expect(license.check('test', 'basic').check).toBe(LICENSE_STATUS.Valid); - expect(license.check('test', 'gold').check).toBe(LICENSE_STATUS.Valid); + expect(license.check('test', 'basic').state).toBe(LICENSE_CHECK_STATE.Valid); + expect(license.check('test', 'gold').state).toBe(LICENSE_CHECK_STATE.Valid); }); test('should return Invalid if active and check does not match', async () => { ({ plugin, license } = await setup()); - const { check } = license.check('test', 'gold'); + const { state } = license.check('test', 'gold'); - expect(check).toBe(LICENSE_STATUS.Invalid); + expect(state).toBe(LICENSE_CHECK_STATE.Invalid); }); test('should return Unavailable if missing license', async () => { - ({ plugin, license } = await setup({ license: null })); + ({ plugin, license } = await setup({ license: null }, {}, false)); - const { check } = license.check('test', 'gold'); + const { state } = license.check('test', 'gold'); - expect(check).toBe(LICENSE_STATUS.Unavailable); + expect(state).toBe(LICENSE_CHECK_STATE.Unavailable); }); test('should return Expired if not active', async () => { @@ -118,9 +118,9 @@ describe('license', () => { }, })); - const { check } = license.check('test', 'basic'); + const { state } = license.check('test', 'basic'); - expect(check).toBe(LICENSE_STATUS.Expired); + expect(state).toBe(LICENSE_CHECK_STATE.Expired); }); }); diff --git a/x-pack/plugins/licensing/server/license.ts b/x-pack/plugins/licensing/server/license.ts deleted file mode 100644 index 4d2d1d3fb41ba..0000000000000 --- a/x-pack/plugins/licensing/server/license.ts +++ /dev/null @@ -1,178 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { createHash } from 'crypto'; -import { LicenseFeature } from './license_feature'; -import { LICENSE_STATUS, LICENSE_TYPE } from './constants'; -import { LicenseType, ILicense } from './types'; - -function toLicenseType(minimumLicenseRequired: LICENSE_TYPE | string) { - if (typeof minimumLicenseRequired !== 'string') { - return minimumLicenseRequired; - } - - if (!(minimumLicenseRequired in LICENSE_TYPE)) { - throw new Error(`${minimumLicenseRequired} is not a valid license type`); - } - - return LICENSE_TYPE[minimumLicenseRequired as LicenseType]; -} - -export class License implements ILicense { - private readonly hasLicense: boolean; - private readonly license: any; - private readonly features: any; - private _signature!: string; - private objectified!: any; - private readonly featuresMap: Map; - - constructor( - license: any, - features: any, - private error: Error | null, - private clusterSource: string - ) { - this.hasLicense = Boolean(license); - this.license = license || {}; - this.features = features; - this.featuresMap = new Map(); - } - - public get uid() { - return this.license.uid; - } - - public get status() { - return this.license.status; - } - - public get isActive() { - return this.status === 'active'; - } - - public get expiryDateInMillis() { - return this.license.expiry_date_in_millis; - } - - public get type() { - return this.license.type; - } - - public get isAvailable() { - return this.hasLicense; - } - - public get isBasic() { - return this.isActive && this.type === 'basic'; - } - - public get isNotBasic() { - return this.isActive && this.type !== 'basic'; - } - - public get reasonUnavailable() { - if (!this.isAvailable) { - return `[${this.clusterSource}] Elasticsearch cluster did not respond with license information.`; - } - - if (this.error instanceof Error && (this.error as any).status === 400) { - return `X-Pack plugin is not installed on the [${this.clusterSource}] Elasticsearch cluster.`; - } - - return this.error; - } - - public get signature() { - if (this._signature !== undefined) { - return this._signature; - } - - this._signature = createHash('md5') - .update(JSON.stringify(this.toObject())) - .digest('hex'); - - return this._signature; - } - - isOneOf(candidateLicenses: string | string[]) { - if (!Array.isArray(candidateLicenses)) { - candidateLicenses = [candidateLicenses]; - } - - return candidateLicenses.includes(this.type); - } - - meetsMinimumOf(minimum: LICENSE_TYPE) { - return LICENSE_TYPE[this.type as LicenseType] >= minimum; - } - - check(pluginName: string, minimumLicenseRequired: LICENSE_TYPE | string) { - const minimum = toLicenseType(minimumLicenseRequired); - - if (!this.isAvailable) { - return { - check: LICENSE_STATUS.Unavailable, - message: i18n.translate('xpack.licensing.check.errorUnavailableMessage', { - defaultMessage: - 'You cannot use {pluginName} because license information is not available at this time.', - values: { pluginName }, - }), - }; - } - - const { type: licenseType } = this.license; - - if (!this.meetsMinimumOf(minimum)) { - return { - check: LICENSE_STATUS.Invalid, - message: i18n.translate('xpack.licensing.check.errorUnsupportedMessage', { - defaultMessage: - 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', - values: { licenseType, pluginName }, - }), - }; - } - - if (!this.isActive) { - return { - check: LICENSE_STATUS.Expired, - message: i18n.translate('xpack.licensing.check.errorExpiredMessage', { - defaultMessage: - 'You cannot use {pluginName} because your {licenseType} license has expired.', - values: { licenseType, pluginName }, - }), - }; - } - - return { check: LICENSE_STATUS.Valid }; - } - - toObject() { - if (this.objectified) { - return this.objectified; - } - - this.objectified = { - license: { - type: this.type, - isActive: this.isActive, - expiryDateInMillis: this.expiryDateInMillis, - }, - features: [...this.featuresMap].map(([, feature]) => feature.toObject()), - }; - - return this.objectified; - } - - getFeature(name: string) { - if (!this.featuresMap.has(name)) { - this.featuresMap.set(name, new LicenseFeature(name, this.features[name], this)); - } - - return this.featuresMap.get(name); - } -} diff --git a/x-pack/plugins/licensing/server/license_feature.test.ts b/x-pack/plugins/licensing/server/license_feature.test.ts index d36fa2cca48ba..5404ca7d0f6f4 100644 --- a/x-pack/plugins/licensing/server/license_feature.test.ts +++ b/x-pack/plugins/licensing/server/license_feature.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILicense } from './types'; +import { ILicense } from '../common/types'; import { Plugin } from './plugin'; import { setup } from './__fixtures__/setup'; @@ -16,27 +16,55 @@ describe('licensing feature', () => { await plugin.stop(); }); - test('isAvailable', async () => { - ({ plugin, license } = await setup()); + describe('valid feature', () => { + test('isAvailable', async () => { + ({ plugin, license } = await setup()); - const security = license.getFeature('security'); + const security = license.getFeature('security'); - expect(security!.isAvailable).toBe(true); - }); + expect(security.isAvailable).toBe(true); + }); + + test('isEnabled', async () => { + ({ plugin, license } = await setup()); + + const security = license.getFeature('security'); - test('isEnabled', async () => { - ({ plugin, license } = await setup()); + expect(security!.isEnabled).toBe(true); + }); - const security = license.getFeature('security'); + test('name', async () => { + ({ plugin, license } = await setup()); - expect(security!.isEnabled).toBe(true); + const security = license.getFeature('security'); + + expect(security.name).toBe('security'); + }); }); - test('name', async () => { - ({ plugin, license } = await setup()); + describe('invalid feature', () => { + test('isAvailable', async () => { + ({ plugin, license } = await setup()); + + const fake = license.getFeature('fake'); + + expect(fake.isAvailable).toBe(false); + }); + + test('isEnabled', async () => { + ({ plugin, license } = await setup()); + + const fake = license.getFeature('fake'); + + expect(fake.isEnabled).toBe(false); + }); + + test('name', async () => { + ({ plugin, license } = await setup()); - const security = license.getFeature('security'); + const fake = license.getFeature('fake'); - expect(security!.name).toBe('security'); + expect(fake.name).toBe('fake'); + }); }); }); diff --git a/x-pack/plugins/licensing/server/license_feature.ts b/x-pack/plugins/licensing/server/license_feature.ts deleted file mode 100644 index 58c5b81e7af74..0000000000000 --- a/x-pack/plugins/licensing/server/license_feature.ts +++ /dev/null @@ -1,34 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { License } from './license'; -import { LicenseFeatureSerializer } from './types'; - -export class LicenseFeature { - private serializable: LicenseFeatureSerializer = license => ({ - name: this.name, - isAvailable: this.isAvailable, - isEnabled: this.isEnabled, - }); - - constructor(public name: string, private feature: any = {}, private license: License) {} - - public get isAvailable() { - return !!this.feature.available; - } - - public get isEnabled() { - return !!this.feature.enabled; - } - - public onObject(serializable: LicenseFeatureSerializer) { - this.serializable = serializable; - } - - public toObject() { - return this.serializable(this.license); - } -} diff --git a/x-pack/plugins/licensing/server/licensing_config.ts b/x-pack/plugins/licensing/server/licensing_config.ts index a5fd3d0a7b046..134e5c36233a2 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -5,8 +5,12 @@ */ import { PluginInitializerContext } from 'src/core/server'; -import { LicensingConfigType } from './types'; +import { TypeOf } from '@kbn/config-schema'; +import { schema } from './schema'; +/** + * Container class for server licensing plugin configuration. + */ export class LicensingConfig { public isEnabled: boolean; public clusterSource: string; @@ -15,7 +19,7 @@ export class LicensingConfig { /** * @internal */ - constructor(rawConfig: LicensingConfigType, env: PluginInitializerContext['env']) { + constructor(rawConfig: TypeOf, env: PluginInitializerContext['env']) { this.isEnabled = rawConfig.isEnabled; this.clusterSource = rawConfig.clusterSource; this.pollingFrequency = rawConfig.pollingFrequency; diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts index 81ad9715da784..22d05e62a0a91 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts @@ -5,28 +5,28 @@ */ import { BehaviorSubject } from 'rxjs'; -import { ILicense } from './types'; +import { first } from 'rxjs/operators'; +import { ILicense } from '../common/types'; import { setup } from './__fixtures__/setup'; import { createRouteHandlerContext } from './licensing_route_handler_context'; describe('licensingRouteHandlerContext', () => { it('provides the initial license value', async () => { - const { license$, license } = await setup(); - + const { license$ } = await setup(); + const initial = await license$.pipe(first()).toPromise(); const context = createRouteHandlerContext(license$); - const { license: contextResult } = await context({}, {} as any, {} as any); + const { license } = await context({}, {} as any, {} as any); - expect(contextResult).toBe(license); + expect(license).toBe(initial); }); it('provides the latest license value', async () => { const { license } = await setup(); const license$ = new BehaviorSubject(license); - const context = createRouteHandlerContext(license$); - const latestLicense = (Symbol() as unknown) as ILicense; + license$.next(latestLicense); const { license: contextResult } = await context({}, {} as any, {} as any); diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts index 8ee49e9aa084f..8b82fea4d7eb6 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts @@ -7,13 +7,17 @@ import { IContextProvider, RequestHandler } from 'src/core/server'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { ILicense } from './types'; +import { ILicense } from '../common/types'; +/** + * Create a route handler context for access to Kibana license information. + * @param license$ An observable of a License instance. + * @public + */ export function createRouteHandlerContext( license$: Observable ): IContextProvider, 'licensing'> { return async function licensingRouteHandlerContext() { - const license = await license$.pipe(take(1)).toPromise(); - return { license }; + return { license: await license$.pipe(take(1)).toPromise() }; }; } diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index 355aeef7f20c7..46998ab7e19b8 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { take, skip } from 'rxjs/operators'; -import { ILicense } from './types'; +import { bufferCount, take, skip } from 'rxjs/operators'; +import { ILicense } from '../common/types'; +import { License } from '../common/license'; +import { licenseMerge } from '../common/license_merge'; +import { delay } from '../common/delay'; import { Plugin } from './plugin'; -import { License } from './license'; -import { setup, setupOnly, licenseMerge } from './__fixtures__/setup'; +import { setup, setupOnly } from './__fixtures__/setup'; describe('licensing plugin', () => { let plugin: Plugin; @@ -47,45 +49,80 @@ describe('licensing plugin', () => { }, }); const types = ['basic', 'gold', 'platinum']; - let iterations = 0; plugin = _plugin; - clusterClient.callAsInternalUser.mockImplementation(() => { - return Promise.resolve( + clusterClient.callAsInternalUser.mockImplementation(() => + Promise.resolve( licenseMerge({ license: { - type: types[iterations++], + type: types.shift(), }, }) - ); - }); + ) + ); const { license$ } = await plugin.setup(coreSetup); - const licenseTypes: any[] = []; - - await new Promise(resolve => { - const subscription = license$.subscribe(next => { - if (!next.type) { - return; - } - - if (iterations > 3) { - subscription.unsubscribe(); - resolve(); - } else { - licenseTypes.push(next.type); - } - }); + const [first, second, third] = await license$ + .pipe( + skip(1), + bufferCount(3), + take(1) + ) + .toPromise(); + + expect([first.type, second.type, third.type]).toEqual(['basic', 'gold', 'platinum']); + }); + + test('polling continues even if there are errors', async () => { + const { clusterClient, coreSetup, plugin: _plugin } = await setupOnly({ + config: { + pollingFrequency: 200, + }, }); + const errors = [new Error('alpha'), new Error('beta'), new Error('gamma')]; + + plugin = _plugin; + // If polling is working through these errors, this mock will continue to be called + clusterClient.callAsInternalUser.mockImplementation(() => Promise.reject(errors.shift())); + + const { license$ } = await plugin.setup(coreSetup); + const [first, second, third] = await license$ + .pipe( + skip(1), + bufferCount(3), + take(1) + ) + .toPromise(); - expect(licenseTypes).toEqual(['basic', 'gold', 'platinum']); + expect([first.error!.message, second.error!.message, third.error!.message]).toEqual([ + 'alpha', + 'beta', + 'gamma', + ]); + }); + + test('calling refresh triggers fetch', async () => { + const { plugin: _plugin, license: _license, license$, clusterClient, refresh } = await setup(); + + // We create a dummy subscription to ensure that calls to refresh actually + // get triggered in the observable. + license$.subscribe(() => {}); + plugin = _plugin; + license = _license; + clusterClient.callAsInternalUser.mockClear(); + refresh(); + await delay(200); + + expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '/_xpack', + }); }); test('provides a licensing context to http routes', async () => { const { coreSetup, plugin: _plugin } = await setupOnly(); plugin = _plugin; - await plugin.setup(coreSetup); expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 4cd40379b8592..41e6ced463ae2 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -4,9 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subject, Subscription, defer, merge, of, timer } from 'rxjs'; +import { + filter, + first, + map, + pairwise, + repeatWhen, + switchMap, + takeUntil, + tap, +} from 'rxjs/operators'; import moment from 'moment'; +import { createHash } from 'crypto'; +import { TypeOf } from '@kbn/config-schema'; import { CoreSetup, CoreStart, @@ -14,11 +25,12 @@ import { Plugin as CorePlugin, PluginInitializerContext, } from 'src/core/server'; -import { Poller } from '../../../../src/core/utils/poller'; -import { LicensingConfigType, LicensingPluginSetup, ILicense } from './types'; +import { LicensingPluginSetup, ILicense } from '../common/types'; +import { License } from '../common/license'; +import { hasLicenseInfoChanged } from '../common/has_license_info_changed'; import { LicensingConfig } from './licensing_config'; -import { License } from './license'; import { createRouteHandlerContext } from './licensing_route_handler_context'; +import { schema } from './schema'; declare module 'src/core/server' { interface RequestHandlerContext { @@ -28,10 +40,57 @@ declare module 'src/core/server' { } } +type LicensingConfigType = TypeOf; + +/** + * Generate the signature for a serialized/stringified license. + */ +function sign(serialized: string) { + return createHash('md5') + .update(serialized) + .digest('hex'); +} + +/** + * @public + * A plugin for fetching, refreshing, and receiving information about the license for the + * current Kibana instance. + */ export class Plugin implements CorePlugin { + /** + * Used as a flag to halt all other plugin observables. + */ + private stop$ = new Subject(); + + /** + * Used to trigger manual fetches of the license information from the server. + */ + private refresher$ = new BehaviorSubject(true); + + /** + * Logger instance bound to `licensing` context. + */ private readonly logger: Logger; + + /** + * An observable of licensing configuration data. + */ private readonly config$: Observable; - private poller!: Poller; + + /** + * The `this.config$` subscription for tracking changes to configuration. + */ + private configSubscription: Subscription; + + /** + * The latest configuration data to come from `this.config$`. + */ + private currentConfig!: LicensingConfig; + + /** + * Instance of the elasticsearch API. + */ + private elasticsearch!: CoreSetup['elasticsearch']; constructor(private readonly context: PluginInitializerContext) { this.logger = this.context.logger.get(); @@ -44,105 +103,92 @@ export class Plugin implements CorePlugin { : new LicensingConfig(config, this.context.env) ) ); + this.configSubscription = this.config$.subscribe(config => { + this.currentConfig = config; + }); } - private hasLicenseInfoChanged(newLicense: any) { - const currentLicense = this.poller.subject$.getValue(); - - if ((currentLicense && !newLicense) || (newLicense && !currentLicense)) { - return true; - } - - return ( - newLicense.type !== currentLicense.type || - newLicense.status !== currentLicense.status || - newLicense.expiry_date_in_millis !== currentLicense.expiryDateInMillis - ); - } - - private async fetchInfo(core: CoreSetup, clusterSource: string, pollingFrequency: number) { - this.logger.debug( - `Calling [${clusterSource}] Elasticsearch _xpack API. Polling frequency: ${pollingFrequency}` - ); - - const cluster = await core.elasticsearch.dataClient$.pipe(first()).toPromise(); - - try { - const response = await cluster.callAsInternalUser('transport.request', { - method: 'GET', - path: '/_xpack', - }); - const rawLicense = response && response.license; - const features = (response && response.features) || {}; - const licenseInfoChanged = this.hasLicenseInfoChanged(rawLicense); - - if (!licenseInfoChanged) { - return { license: false, error: null, features: null }; - } + /** + * Initialize the plugin for consumption. + * @param core + */ + public async setup(core: CoreSetup) { + this.elasticsearch = core.elasticsearch; + + let signature = ''; + const { clusterSource, pollingFrequency } = this.currentConfig; + const initial$ = of(new License({ features: {}, clusterSource })); + const setup = { + refresh: () => this.refresher$.next(true), + license$: initial$, + }; - const currentLicense = this.poller.subject$.getValue(); - const licenseInfo = [ - 'type' in rawLicense && `type: ${rawLicense.type}`, - 'status' in rawLicense && `status: ${rawLicense.status}`, - 'expiry_date_in_millis' in rawLicense && - `expiry date: ${moment(rawLicense.expiry_date_in_millis, 'x').format()}`, - ] - .filter(Boolean) - .join(' | '); - - this.logger.info( - `Imported ${currentLicense ? 'changed ' : ''}license information` + - ` from Elasticsearch for the [${clusterSource}] cluster: ${licenseInfo}` - ); + // The license fetches occur in a defer/repeatWhen pair to avoid race conditions between refreshes and timers + const licenseFetches$ = defer(async () => { + const config = this.currentConfig; - return { license: rawLicense, error: null, features }; - } catch (err) { - this.logger.warn( - `License information could not be obtained from Elasticsearch` + - ` for the [${clusterSource}] cluster. ${err}` + this.logger.debug( + `Calling [${config.clusterSource}] Elasticsearch _xpack API. Polling frequency: ${config.pollingFrequency}` ); - return { license: null, error: err, features: {} }; - } - } - - private create({ clusterSource, pollingFrequency }: LicensingConfig, core: CoreSetup) { - this.poller = new Poller( - pollingFrequency, - new License(null, {}, null, clusterSource), - async () => { - const { license, features, error } = await this.fetchInfo( - core, - clusterSource, - pollingFrequency + try { + const cluster = await this.elasticsearch.dataClient$.pipe(first()).toPromise(); + const response = await cluster.callAsInternalUser('transport.request', { + method: 'GET', + path: '/_xpack', + }); + const rawLicense = response && response.license; + const features = (response && response.features) || {}; + + return new License({ + license: rawLicense, + features, + clusterSource: config.clusterSource, + }); + } catch (err) { + this.logger.warn( + `License information could not be obtained from Elasticsearch for the [${config.clusterSource}] cluster. ${err}` ); - if (license !== false) { - return new License(license, features, error, clusterSource); - } + return new License({ + features: {}, + error: err, + clusterSource: config.clusterSource, + }); } + }).pipe(repeatWhen(complete$ => complete$.pipe(switchMap(() => timer(0, pollingFrequency))))); + const updates$ = merge(initial$, licenseFetches$).pipe( + pairwise(), + filter(([previous, next]) => hasLicenseInfoChanged(previous, next)), + map(([, next]) => next) ); - return this.poller; - } - - public async setup(core: CoreSetup) { - const config = await this.config$.pipe(first()).toPromise(); - const poller = this.create(config, core); - const license$ = poller.subject$.asObservable(); - - core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$)); + setup.license$ = merge(initial$, updates$).pipe( + takeUntil(this.stop$), + tap(license => { + signature = sign(JSON.stringify(license.toObject())); + this.logger.info( + `Imported license information from Elasticsearch for the [${this.currentConfig.clusterSource}] cluster: ` + + `type: ${license.type} | status: ${license.status} | expiry date: ${moment( + license.expiryDateInMillis, + 'x' + ).format()} | signature: ${signature}` + ); + }) + ); + core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(setup.license$)); - return { - license$, - }; + return setup; } public async start(core: CoreStart) {} + /** + * Halt the plugin's operations and observables. + */ public stop() { - if (this.poller) { - this.poller.unsubscribe(); - } + this.stop$.next(); + this.stop$.complete(); + this.configSubscription.unsubscribe(); } } diff --git a/x-pack/plugins/licensing/server/schema.ts b/x-pack/plugins/licensing/server/schema.ts index cfc467677f7b6..21102f623ddfb 100644 --- a/x-pack/plugins/licensing/server/schema.ts +++ b/x-pack/plugins/licensing/server/schema.ts @@ -5,7 +5,7 @@ */ import { schema as Schema } from '@kbn/config-schema'; -import { DEFAULT_POLLING_FREQUENCY } from './constants'; +import { DEFAULT_POLLING_FREQUENCY } from '../common/constants'; export const schema = Schema.object({ isEnabled: Schema.boolean({ defaultValue: true }), diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts index 5bb811ef6be4c..0bc1685345857 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts @@ -5,13 +5,13 @@ */ import { RequestHandlerContext } from 'src/core/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../../../licensing/server'; export const mockRouteContext = ({ licensing: { license: { check: jest.fn().mockReturnValue({ - check: LICENSE_STATUS.Valid, + state: LICENSE_CHECK_STATE.Valid, }), }, }, @@ -21,7 +21,7 @@ export const mockRouteContextWithInvalidLicense = ({ licensing: { license: { check: jest.fn().mockReturnValue({ - check: LICENSE_STATUS.Invalid, + state: LICENSE_CHECK_STATE.Invalid, message: 'License is invalid for spaces', }), }, diff --git a/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts index a3bc2fa71fefe..3838b1d134ea2 100644 --- a/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts +++ b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts @@ -6,7 +6,7 @@ import { RequestHandler } from 'src/core/server'; import { ObjectType } from '@kbn/config-schema'; -import { LICENSE_STATUS } from '../../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; export const createLicensedRouteHandler = < P extends ObjectType, @@ -19,8 +19,8 @@ export const createLicensedRouteHandler = < const { license } = context.licensing; const licenseCheck = license.check('spaces', 'basic'); if ( - licenseCheck.check === LICENSE_STATUS.Unavailable || - licenseCheck.check === LICENSE_STATUS.Invalid + licenseCheck.state === LICENSE_CHECK_STATE.Unavailable || + licenseCheck.state === LICENSE_CHECK_STATE.Invalid ) { return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); } diff --git a/yarn.lock b/yarn.lock index 35fa03470254a..72229500e47ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16782,6 +16782,11 @@ jest-leak-detector@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" +jest-localstorage-mock@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.0.tgz#c6073810735dd3af74020ea6c3885ec1cc6d0d13" + integrity sha512-/mC1JxnMeuIlAaQBsDMilskC/x/BicsQ/BXQxEOw+5b1aGZkkOAqAF3nu8yq449CpzGtp5jJ5wCmDNxLgA2m6A== + jest-matcher-utils@^24.0.0: version "24.0.0" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.0.0.tgz#fc9c41cfc49b2c3ec14e576f53d519c37729d579" @@ -29505,8 +29510,6 @@ wbuf@^1.1.0: version "1.7.2" resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.2.tgz#d697b99f1f59512df2751be42769c1580b5801fe" integrity sha1-1pe5nx9ZUS3ydRvkJ2nBWAtYAf4= - dependencies: - minimalistic-assert "^1.0.0" wbuf@^1.7.3: version "1.7.3"