From 4667c46caef26f8f47714504879197708debae32 Mon Sep 17 00:00:00 2001 From: Eli Perelman Date: Thu, 29 Aug 2019 18:28:31 -0500 Subject: [PATCH] Add x-pack plugin for new platform server licensing information (#43623) * Add x-pack plugin for new platform server licensing information * Update x-pack translations * Implement core plugin interface for licensing plugin: * Rename references to service to plugin * Use CoreStart in licensing start method * Fix outstanding reference error * Fix type check errors * Address outstanding review comments * Fix type error in tests * Address review comments, move polling logic to standalone * Split up test files * Fix bad reference in test * Use relative reference to poller util * Add more plugin tests to address review comments * Fix different manners of config generation in licensing plugin * Update test fixtures * Fix path to test fixtures --- src/core/utils/poller.test.ts | 70 +++++++ src/core/utils/poller.ts | 55 ++++++ x-pack/.i18nrc.json | 1 + x-pack/plugins/licensing/kibana.json | 8 + .../licensing/server/__fixtures__/setup.ts | 115 +++++++++++ x-pack/plugins/licensing/server/constants.ts | 21 ++ x-pack/plugins/licensing/server/index.ts | 13 ++ .../plugins/licensing/server/license.test.ts | 180 ++++++++++++++++++ x-pack/plugins/licensing/server/license.ts | 178 +++++++++++++++++ .../licensing/server/license_feature.test.ts | 42 ++++ .../licensing/server/license_feature.ts | 34 ++++ .../licensing/server/licensing_config.ts | 23 +++ .../plugins/licensing/server/plugin.test.ts | 83 ++++++++ x-pack/plugins/licensing/server/plugin.ts | 136 +++++++++++++ x-pack/plugins/licensing/server/schema.ts | 14 ++ x-pack/plugins/licensing/server/types.ts | 120 ++++++++++++ .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- 18 files changed, 1099 insertions(+), 6 deletions(-) create mode 100644 src/core/utils/poller.test.ts create mode 100644 src/core/utils/poller.ts create mode 100644 x-pack/plugins/licensing/kibana.json create mode 100644 x-pack/plugins/licensing/server/__fixtures__/setup.ts create mode 100644 x-pack/plugins/licensing/server/constants.ts create mode 100644 x-pack/plugins/licensing/server/index.ts create mode 100644 x-pack/plugins/licensing/server/license.test.ts create mode 100644 x-pack/plugins/licensing/server/license.ts create mode 100644 x-pack/plugins/licensing/server/license_feature.test.ts create mode 100644 x-pack/plugins/licensing/server/license_feature.ts create mode 100644 x-pack/plugins/licensing/server/licensing_config.ts create mode 100644 x-pack/plugins/licensing/server/plugin.test.ts create mode 100644 x-pack/plugins/licensing/server/plugin.ts create mode 100644 x-pack/plugins/licensing/server/schema.ts create mode 100644 x-pack/plugins/licensing/server/types.ts diff --git a/src/core/utils/poller.test.ts b/src/core/utils/poller.test.ts new file mode 100644 index 0000000000000..85eb7da06c182 --- /dev/null +++ b/src/core/utils/poller.test.ts @@ -0,0 +1,70 @@ +/* + * 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)); + +describe('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 new file mode 100644 index 0000000000000..7c50db74bcefb --- /dev/null +++ b/src/core/utils/poller.ts @@ -0,0 +1,55 @@ +/* + * 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/.i18nrc.json b/x-pack/.i18nrc.json index 24f8ce79d70a6..8aa39499f1c0d 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -17,6 +17,7 @@ "xpack.indexLifecycleMgmt": "legacy/plugins/index_lifecycle_management", "xpack.infra": "legacy/plugins/infra", "xpack.kueryAutocomplete": "legacy/plugins/kuery_autocomplete", + "xpack.licensing": "plugins/licensing", "xpack.licenseMgmt": "legacy/plugins/license_management", "xpack.maps": "legacy/plugins/maps", "xpack.ml": "legacy/plugins/ml", diff --git a/x-pack/plugins/licensing/kibana.json b/x-pack/plugins/licensing/kibana.json new file mode 100644 index 0000000000000..a76ce1ef6a23c --- /dev/null +++ b/x-pack/plugins/licensing/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "licensing", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["x-pack", "licensing"], + "server": true, + "ui": false +} diff --git a/x-pack/plugins/licensing/server/__fixtures__/setup.ts b/x-pack/plugins/licensing/server/__fixtures__/setup.ts new file mode 100644 index 0000000000000..a0cb1ea1a2b67 --- /dev/null +++ b/x-pack/plugins/licensing/server/__fixtures__/setup.ts @@ -0,0 +1,115 @@ +/* + * 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 { merge } from 'lodash'; +import { ClusterClient } from 'src/core/server'; +import { coreMock } from '../../../../../src/core/server/mocks'; +import { Plugin } from '../plugin'; +import { schema } from '../schema'; + +export async function licenseMerge(xpackInfo = {}) { + return 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 + ); +} + +export async function setupOnly(pluginInitializerContext: any = {}) { + const coreSetup = coreMock.createSetup(); + const clusterClient = ((await coreSetup.elasticsearch.dataClient$ + .pipe(take(1)) + .toPromise()) as unknown) as jest.Mocked>; + const plugin = new Plugin( + coreMock.createPluginInitializerContext({ + config: schema.validate(pluginInitializerContext.config || {}), + }) + ); + + return { coreSetup, plugin, clusterClient }; +} + +export async function setup(xpackInfo = {}, pluginInitializerContext: any = {}) { + 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(); + + return { + plugin, + license$, + license, + clusterClient, + }; +} diff --git a/x-pack/plugins/licensing/server/constants.ts b/x-pack/plugins/licensing/server/constants.ts new file mode 100644 index 0000000000000..f2823ea00933c --- /dev/null +++ b/x-pack/plugins/licensing/server/constants.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export const SERVICE_NAME = 'licensing'; +export const DEFAULT_POLLING_FREQUENCY = 30001; // 30 seconds +export enum LICENSE_STATUS { + Unavailable = 'UNAVAILABLE', + Invalid = 'INVALID', + Expired = 'EXPIRED', + Valid = 'VALID', +} +export enum LICENSE_TYPE { + basic = 10, + standard = 20, + gold = 30, + platinum = 40, + trial = 50, +} diff --git a/x-pack/plugins/licensing/server/index.ts b/x-pack/plugins/licensing/server/index.ts new file mode 100644 index 0000000000000..49415b63bc3b7 --- /dev/null +++ b/x-pack/plugins/licensing/server/index.ts @@ -0,0 +1,13 @@ +/* + * 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/server'; +import { schema } from './schema'; +import { Plugin } from './plugin'; + +export * from './types'; +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 new file mode 100644 index 0000000000000..1c308a6280449 --- /dev/null +++ b/x-pack/plugins/licensing/server/license.test.ts @@ -0,0 +1,180 @@ +/* + * 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 './types'; +import { Plugin } from './plugin'; +import { LICENSE_STATUS } from './constants'; +import { LicenseFeature } from './license_feature'; +import { setup } from './__fixtures__/setup'; + +describe('license', () => { + let plugin: Plugin; + let license: ILicense; + + afterEach(async () => { + await plugin.stop(); + }); + + 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); + }); + + 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').check).toBe(LICENSE_STATUS.Valid); + expect(license.check('test', 'gold').check).toBe(LICENSE_STATUS.Valid); + }); + + test('should return Invalid if active and check does not match', async () => { + ({ plugin, license } = await setup()); + + const { check } = license.check('test', 'gold'); + + expect(check).toBe(LICENSE_STATUS.Invalid); + }); + + test('should return Unavailable if missing license', async () => { + ({ plugin, license } = await setup({ license: null })); + + const { check } = license.check('test', 'gold'); + + expect(check).toBe(LICENSE_STATUS.Unavailable); + }); + + test('should return Expired if not active', async () => { + ({ plugin, license } = await setup({ + license: { + status: 'not-active', + }, + })); + + const { check } = license.check('test', 'basic'); + + expect(check).toBe(LICENSE_STATUS.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/server/license.ts b/x-pack/plugins/licensing/server/license.ts new file mode 100644 index 0000000000000..4d2d1d3fb41ba --- /dev/null +++ b/x-pack/plugins/licensing/server/license.ts @@ -0,0 +1,178 @@ +/* + * 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 new file mode 100644 index 0000000000000..d36fa2cca48ba --- /dev/null +++ b/x-pack/plugins/licensing/server/license_feature.test.ts @@ -0,0 +1,42 @@ +/* + * 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 './types'; +import { Plugin } from './plugin'; +import { setup } from './__fixtures__/setup'; + +describe('licensing feature', () => { + let plugin: Plugin; + let license: ILicense; + + afterEach(async () => { + await plugin.stop(); + }); + + 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'); + }); +}); diff --git a/x-pack/plugins/licensing/server/license_feature.ts b/x-pack/plugins/licensing/server/license_feature.ts new file mode 100644 index 0000000000000..58c5b81e7af74 --- /dev/null +++ b/x-pack/plugins/licensing/server/license_feature.ts @@ -0,0 +1,34 @@ +/* + * 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 new file mode 100644 index 0000000000000..a5fd3d0a7b046 --- /dev/null +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -0,0 +1,23 @@ +/* + * 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/server'; +import { LicensingConfigType } from './types'; + +export class LicensingConfig { + public isEnabled: boolean; + public clusterSource: string; + public pollingFrequency: number; + + /** + * @internal + */ + constructor(rawConfig: LicensingConfigType, env: PluginInitializerContext['env']) { + this.isEnabled = rawConfig.isEnabled; + this.clusterSource = rawConfig.clusterSource; + this.pollingFrequency = rawConfig.pollingFrequency; + } +} diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts new file mode 100644 index 0000000000000..e8d3b651620ba --- /dev/null +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { ILicense } from './types'; +import { Plugin } from './plugin'; +import { License } from './license'; +import { setup, setupOnly, licenseMerge } from './__fixtures__/setup'; + +describe('licensing plugin', () => { + let plugin: Plugin; + let license: ILicense; + + afterEach(async () => { + await plugin.stop(); + }); + + test('returns instance of licensing setup', async () => { + ({ plugin, license } = await setup()); + expect(license).toBeInstanceOf(License); + }); + + test('still returns instance of licensing setup when request fails', async () => { + const { clusterClient, coreSetup, plugin: _plugin } = await setupOnly(); + + plugin = _plugin; + clusterClient.callAsInternalUser.mockRejectedValue(new Error('test')); + + const { license$ } = await plugin.setup(coreSetup); + const finalLicense = await license$ + .pipe( + skip(1), + take(1) + ) + .toPromise(); + + expect(finalLicense).toBeInstanceOf(License); + }); + + test('observable receives updated licenses', async () => { + const { clusterClient, coreSetup, plugin: _plugin } = await setupOnly({ + config: { + pollingFrequency: 100, + }, + }); + const types = ['basic', 'gold', 'platinum']; + let iterations = 0; + + plugin = _plugin; + clusterClient.callAsInternalUser.mockImplementation(() => { + return Promise.resolve( + licenseMerge({ + license: { + type: types[iterations++], + }, + }) + ); + }); + + 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); + } + }); + }); + + expect(licenseTypes).toEqual(['basic', 'gold', 'platinum']); + }); +}); diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts new file mode 100644 index 0000000000000..7c9a0df15229f --- /dev/null +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -0,0 +1,136 @@ +/* + * 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 { first, map } from 'rxjs/operators'; +import moment from 'moment'; +import { + CoreSetup, + CoreStart, + Logger, + Plugin as CorePlugin, + PluginInitializerContext, +} from 'src/core/server'; +import { Poller } from '../../../../src/core/utils/poller'; +import { LicensingConfigType, LicensingPluginSetup, ILicense } from './types'; +import { LicensingConfig } from './licensing_config'; +import { License } from './license'; + +export class Plugin implements CorePlugin { + private readonly logger: Logger; + private readonly config$: Observable; + private poller!: Poller; + + constructor(private readonly context: PluginInitializerContext) { + this.logger = this.context.logger.get(); + this.config$ = this.context.config + .create() + .pipe( + map(config => + 'config' in config + ? new LicensingConfig(config.config, this.context.env) + : new LicensingConfig(config, this.context.env) + ) + ); + } + + 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 }; + } + + 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}` + ); + + return { license: rawLicense, error: null, features }; + } catch (err) { + this.logger.warn( + `License information could not be obtained from Elasticsearch` + + ` for the [${clusterSource}] cluster. ${err}` + ); + + 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 + ); + + if (license !== false) { + return new License(license, features, error, clusterSource); + } + } + ); + + return this.poller; + } + + public async setup(core: CoreSetup) { + const config = await this.config$.pipe(first()).toPromise(); + const poller = this.create(config, core); + + return { + license$: poller.subject$.asObservable(), + }; + } + + public async start(core: CoreStart) {} + + public stop() { + if (this.poller) { + this.poller.unsubscribe(); + } + } +} diff --git a/x-pack/plugins/licensing/server/schema.ts b/x-pack/plugins/licensing/server/schema.ts new file mode 100644 index 0000000000000..cfc467677f7b6 --- /dev/null +++ b/x-pack/plugins/licensing/server/schema.ts @@ -0,0 +1,14 @@ +/* + * 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 { schema as Schema } from '@kbn/config-schema'; +import { DEFAULT_POLLING_FREQUENCY } from './constants'; + +export const schema = Schema.object({ + isEnabled: Schema.boolean({ defaultValue: true }), + clusterSource: Schema.string({ defaultValue: 'data' }), + pollingFrequency: Schema.number({ defaultValue: DEFAULT_POLLING_FREQUENCY }), +}); diff --git a/x-pack/plugins/licensing/server/types.ts b/x-pack/plugins/licensing/server/types.ts new file mode 100644 index 0000000000000..a3ceea327c7b0 --- /dev/null +++ b/x-pack/plugins/licensing/server/types.ts @@ -0,0 +1,120 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { schema } from './schema'; +import { LICENSE_TYPE, LICENSE_STATUS } from './constants'; +import { LicenseFeature } from './license_feature'; + +/** + * @public + * Results from checking if a particular license type meets the minimum + * requirements of the license type. + */ +export interface ILicenseCheck { + /** + * The status of checking the results of a license type meeting the license minimum. + */ + check: LICENSE_STATUS; + /** + * A message containing the reason for a license type not being valid. + */ + message?: string; +} +/** @public */ +export interface ILicense { + /** + * UID for license. + */ + uid?: string; + + /** + * The validity status of the license. + */ + status?: string; + + /** + * Determine if the status of the license is active. + */ + isActive: boolean; + + /** + * Unix epoch of the expiration date of the license. + */ + expiryDateInMillis?: number; + + /** + * The license type, being usually one of basic, standard, gold, platinum, or trial. + */ + type?: string; + + /** + * Determine if the license container has information. + */ + isAvailable: boolean; + + /** + * Determine if the type of the license is basic, and also active. + */ + isBasic: boolean; + + /** + * Determine if the type of the license is not basic, and also active. + */ + isNotBasic: boolean; + + /** + * 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; + + /** + * Determine if the provided license types match against the license type. + * @param candidateLicenses license types to intersect against the license. + */ + isOneOf(candidateLicenses: string | string[]): boolean; + + /** + * 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): boolean; + + /** + * 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): ILicenseCheck; + + /** + * Receive a serialized plain object of the license. + */ + toObject(): any; + + /** + * 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; +} + +/** @public */ +export interface LicensingPluginSetup { + license$: Observable; +} +/** @public */ +export type LicensingConfigType = TypeOf; +/** @public */ +export type LicenseType = keyof typeof LICENSE_TYPE; +/** @public */ +export type LicenseFeatureSerializer = (licensing: ILicense) => any; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 98af4d5c21eb2..c447c72d66bf4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5287,6 +5287,9 @@ "xpack.licenseMgmt.uploadLicense.uploadButtonLabel": "アップロード", "xpack.licenseMgmt.uploadLicense.uploadingButtonLabel": "アップロード中…", "xpack.licenseMgmt.uploadLicense.uploadLicenseTitle": "ライセンスのアップロード", + "xpack.licensing.check.errorExpiredMessage": "{licenseType} ライセンスが期限切れのため {pluginName} を使用できません", + "xpack.licensing.check.errorUnavailableMessage": "現在ライセンス情報が利用できないため {pluginName} を使用できません。", + "xpack.licensing.check.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。", "xpack.logstash.addRoleAlert.grantAdditionalPrivilegesDescription": "Kibana の管理で、Kibana ユーザーに {role} ロールを割り当ててください。", "xpack.logstash.addRoleAlert.grantAdditionalPrivilegesTitle": "追加権限の授与。", "xpack.logstash.alertCallOut.howToSeeAdditionalPipelinesDescription": "追加パイプラインを表示させる方法", @@ -9042,9 +9045,6 @@ "xpack.security.roles.createBreadcrumb": "作成", "xpack.security.users.breadcrumb": "ユーザー", "xpack.security.users.createBreadcrumb": "作成", - "xpack.server.checkLicense.errorExpiredMessage": "{licenseType} ライセンスが期限切れのため {pluginName} を使用できません", - "xpack.server.checkLicense.errorUnavailableMessage": "現在ライセンス情報が利用できないため {pluginName} を使用できません。", - "xpack.server.checkLicense.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。", "xpack.siem.add_to_kql.filterForValueHoverAction": "値でフィルターします", "xpack.siem.andOrBadge.and": "AND", "xpack.siem.andOrBadge.or": "OR", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 630e8220a1de5..d02898938ce17 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5430,6 +5430,9 @@ "xpack.licenseMgmt.uploadLicense.uploadButtonLabel": "上传", "xpack.licenseMgmt.uploadLicense.uploadingButtonLabel": "正在上传……", "xpack.licenseMgmt.uploadLicense.uploadLicenseTitle": "上传您的许可", + "xpack.licensing.check.errorExpiredMessage": "您不能使用 {pluginName},因为您的 {licenseType} 许可证已过期", + "xpack.licensing.check.errorUnavailableMessage": "您不能使用 {pluginName},因为许可证信息当前不可用。", + "xpack.licensing.check.errorUnsupportedMessage": "您的 {licenseType} 许可证不支持 {pluginName}。请升级您的许可。", "xpack.logstash.addRoleAlert.grantAdditionalPrivilegesDescription": "在 Kibana“管理”中,将 {role} 角色分配给您的 Kibana 用户。", "xpack.logstash.addRoleAlert.grantAdditionalPrivilegesTitle": "授予其他权限。", "xpack.logstash.alertCallOut.howToSeeAdditionalPipelinesDescription": "我如何可以看到其他管道?", @@ -9184,9 +9187,6 @@ "xpack.security.roles.createBreadcrumb": "创建", "xpack.security.users.breadcrumb": "用户", "xpack.security.users.createBreadcrumb": "创建", - "xpack.server.checkLicense.errorExpiredMessage": "您不能使用 {pluginName},因为您的 {licenseType} 许可证已过期", - "xpack.server.checkLicense.errorUnavailableMessage": "您不能使用 {pluginName},因为许可证信息当前不可用。", - "xpack.server.checkLicense.errorUnsupportedMessage": "您的 {licenseType} 许可证不支持 {pluginName}。请升级您的许可。", "xpack.siem.add_to_kql.filterForValueHoverAction": "筛留值", "xpack.siem.andOrBadge.and": "AND", "xpack.siem.andOrBadge.or": "OR",