Revert "Revert "Fix wrong impor (#52994)""

This reverts commit c220d770cc.
This commit is contained in:
spalger 2019-12-13 13:24:14 -07:00
parent c220d770cc
commit 1ea9d791bc
40 changed files with 718 additions and 769 deletions

View file

@ -346,7 +346,7 @@ module.exports = {
'Server modules cannot be imported into client modules or shared modules.',
},
{
target: ['src/core/**/*'],
target: ['src/**/*'],
from: ['x-pack/**/*'],
errorMessage: 'OSS cannot import x-pack files.',
},

View file

@ -8,4 +8,7 @@ export declare class Poller {
constructor(options: any);
public start(): void;
public stop(): void;
public isRunning(): boolean;
public getPollFrequency(): number;
}

View file

@ -17,6 +17,7 @@ import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_regis
import { npSetup, npStart } from 'ui/new_platform';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { start as navigation } from '../../../../../src/legacy/core_plugins/navigation/public/legacy';
import { LicensingPluginSetup } from '../../../../plugins/licensing/public';
import { GraphPlugin } from './plugin';
// @ts-ignore
@ -39,13 +40,17 @@ async function getAngularInjectedDependencies(): Promise<LegacyAngularInjectedDe
};
}
type XpackNpSetupDeps = typeof npSetup.plugins & {
licensing: LicensingPluginSetup;
};
(async () => {
const instance = new GraphPlugin();
instance.setup(npSetup.core, {
__LEGACY: {
Storage,
},
...npSetup.plugins,
...(npSetup.plugins as XpackNpSetupDeps),
});
instance.start(npStart.core, {
npData: npStart.plugins.data,

View file

@ -9,7 +9,7 @@ import { CoreSetup, CoreStart, Plugin, SavedObjectsClientContract } from 'src/co
import { Plugin as DataPlugin } from 'src/plugins/data/public';
import { LegacyAngularInjectedDependencies } from './render_app';
import { NavigationStart } from '../../../../../src/legacy/core_plugins/navigation/public';
import { LicensingPluginSetup } from '../../../../plugins/licensing/common/types';
import { LicensingPluginSetup } from '../../../../plugins/licensing/public';
export interface GraphPluginStartDependencies {
npData: ReturnType<DataPlugin['start']>;

View file

@ -38,7 +38,7 @@ import {
IndexPatternsContract,
} from '../../../../../src/plugins/data/public';
import { NavigationStart } from '../../../../../src/legacy/core_plugins/navigation/public';
import { LicensingPluginSetup } from '../../../../plugins/licensing/common/types';
import { LicensingPluginSetup } from '../../../../plugins/licensing/public';
import { checkLicense } from '../../../../plugins/graph/common/check_license';
/**

View file

@ -6,9 +6,6 @@
import { resolve } from 'path';
import dedent from 'dedent';
import {
XPACK_INFO_API_DEFAULT_POLL_FREQUENCY_IN_MILLIS
} from '../../server/lib/constants';
import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status';
import { replaceInjectedVars } from './server/lib/replace_injected_vars';
import { setupXPackMain } from './server/lib/setup_xpack_main';
@ -34,7 +31,6 @@ export const xpackMain = (kibana) => {
enabled: Joi.boolean().default(),
url: Joi.string().default(),
}).default(), // deprecated
xpack_api_polling_frequency_millis: Joi.number().default(XPACK_INFO_API_DEFAULT_POLL_FREQUENCY_IN_MILLIS),
}).default();
},
@ -47,6 +43,9 @@ export const xpackMain = (kibana) => {
},
uiExports: {
hacks: [
'plugins/xpack_main/hacks/check_xpack_info_change',
],
replaceInjectedVars,
injectDefaultVars(server) {
const config = server.config();

View file

@ -0,0 +1,53 @@
/*
* 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 { identity } from 'lodash';
import { uiModules } from 'ui/modules';
import { Path } from 'plugins/xpack_main/services/path';
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
import { xpackInfoSignature } from 'plugins/xpack_main/services/xpack_info_signature';
const module = uiModules.get('xpack_main', []);
module.factory('checkXPackInfoChange', ($q, Private, $injector) => {
/**
* Intercept each network response to look for the kbn-xpack-sig header.
* When that header is detected, compare its value with the value cached
* in the browser storage. When the value is new, call `xpackInfo.refresh()`
* so that it will pull down the latest x-pack info
*
* @param {object} response - the angular $http response object
* @param {function} handleResponse - callback, expects to receive the response
* @return
*/
function interceptor(response, handleResponse) {
if (Path.isUnauthenticated()) {
return handleResponse(response);
}
const currentSignature = response.headers('kbn-xpack-sig');
const cachedSignature = xpackInfoSignature.get();
if (currentSignature && cachedSignature !== currentSignature) {
// Signature from the server differ from the signature of our
// cached info, so we need to refresh it.
// Intentionally swallowing this error
// because nothing catches it and it's an ugly console error.
xpackInfo.refresh($injector).catch(() => {});
}
return handleResponse(response);
}
return {
response: (response) => interceptor(response, identity),
responseError: (response) => interceptor(response, $q.reject)
};
});
module.config(($httpProvider) => {
$httpProvider.interceptors.push('checkXPackInfoChange');
});

View file

@ -4,11 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { BehaviorSubject } from 'rxjs';
import sinon from 'sinon';
import { XPackInfo } from '../xpack_info';
import { setupXPackMain } from '../setup_xpack_main';
import * as InjectXPackInfoSignatureNS from '../inject_xpack_info_signature';
describe('setupXPackMain()', () => {
const sandbox = sinon.createSandbox();
@ -39,7 +41,7 @@ describe('setupXPackMain()', () => {
elasticsearch: mockElasticsearchPlugin,
xpack_main: mockXPackMainPlugin
},
newPlatform: { setup: { plugins: { features: {} } } },
newPlatform: { setup: { plugins: { features: {}, licensing: { license$: new BehaviorSubject() } } } },
events: { on() {} },
log() {},
config() {},
@ -47,9 +49,8 @@ describe('setupXPackMain()', () => {
ext() {}
});
// Make sure we don't misspell config key.
// Make sure plugins doesn't consume config
const configGetStub = sinon.stub().throws(new Error('`config.get` is called with unexpected key.'));
configGetStub.withArgs('xpack.xpack_main.xpack_api_polling_frequency_millis').returns(1234);
mockServer.config.returns({ get: configGetStub });
});

View file

@ -5,36 +5,32 @@
*/
import { createHash } from 'crypto';
import { BehaviorSubject } from 'rxjs';
import expect from '@kbn/expect';
import sinon from 'sinon';
import { XPackInfo } from '../xpack_info';
import { licensingMock } from '../../../../../../plugins/licensing/server/mocks';
const nowDate = new Date(2010, 10, 10);
function getMockXPackInfoAPIResponse(license = {}, features = {}) {
return Promise.resolve({
build: {
hash: '5927d85',
date: '2010-10-10T00:00:00.000Z'
},
function createLicense(license = {}, features = {}) {
return licensingMock.createLicense({
license: {
uid: 'custom-uid',
type: 'gold',
mode: 'gold',
status: 'active',
expiry_date_in_millis: 1286575200000,
expiryDateInMillis: 1286575200000,
...license
},
features: {
security: {
description: 'Security for the Elastic Stack',
available: true,
enabled: true
isAvailable: true,
isEnabled: true
},
watcher: {
description: 'Alerting, Notification and Automation for the Elastic Stack',
available: true,
enabled: false
isAvailable: true,
isEnabled: false
},
...features
}
@ -48,244 +44,63 @@ function getSignature(object) {
}
describe('XPackInfo', () => {
const sandbox = sinon.createSandbox();
let mockServer;
let mockElasticsearchCluster;
let mockElasticsearchPlugin;
beforeEach(() => {
sandbox.useFakeTimers(nowDate.getTime());
mockElasticsearchCluster = {
callWithInternalUser: sinon.stub()
};
mockElasticsearchPlugin = {
getCluster: sinon.stub().returns(mockElasticsearchCluster)
};
mockServer = sinon.stub({
plugins: { elasticsearch: mockElasticsearchPlugin },
events: { on() {} },
log() { }
newPlatform: {
setup: {
plugins: {
licensing: {
}
}
}
},
});
});
afterEach(() => sandbox.restore());
it('correctly initializes its own properties with defaults.', () => {
mockElasticsearchPlugin.getCluster.throws(new Error('`getCluster` is called with unexpected source.'));
mockElasticsearchPlugin.getCluster.withArgs('data').returns(mockElasticsearchCluster);
const xPackInfo = new XPackInfo(mockServer, { pollFrequencyInMillis: 1500 });
expect(xPackInfo.isAvailable()).to.be(false);
expect(xPackInfo.license.isActive()).to.be(false);
expect(xPackInfo.unavailableReason()).to.be(undefined);
// Poller is not started.
sandbox.clock.tick(10000);
sinon.assert.notCalled(mockElasticsearchCluster.callWithInternalUser);
});
it('correctly initializes its own properties with custom cluster type.', () => {
mockElasticsearchPlugin.getCluster.throws(new Error('`getCluster` is called with unexpected source.'));
mockElasticsearchPlugin.getCluster.withArgs('monitoring').returns(mockElasticsearchCluster);
const xPackInfo = new XPackInfo(
mockServer,
{ clusterSource: 'monitoring', pollFrequencyInMillis: 1234 }
);
expect(xPackInfo.isAvailable()).to.be(false);
expect(xPackInfo.license.isActive()).to.be(false);
expect(xPackInfo.unavailableReason()).to.be(undefined);
// Poller is not started.
sandbox.clock.tick(9999);
sinon.assert.notCalled(mockElasticsearchCluster.callWithInternalUser);
});
describe('refreshNow()', () => {
let xPackInfo;
beforeEach(async () => {
mockElasticsearchCluster.callWithInternalUser.returns(getMockXPackInfoAPIResponse());
it('delegates to the new platform licensing plugin', async () => {
const refresh = sinon.spy();
xPackInfo = new XPackInfo(mockServer, { pollFrequencyInMillis: 1500 });
await xPackInfo.refreshNow();
});
it('forces xpack info to be immediately updated with the data returned from Elasticsearch API.', async () => {
sinon.assert.calledOnce(mockElasticsearchCluster.callWithInternalUser);
sinon.assert.calledWithExactly(mockElasticsearchCluster.callWithInternalUser, 'transport.request', {
method: 'GET',
path: '/_xpack'
const xPackInfo = new XPackInfo(mockServer, {
licensing: {
license$: new BehaviorSubject(createLicense()),
refresh: refresh
}
});
expect(xPackInfo.isAvailable()).to.be(true);
expect(xPackInfo.license.isActive()).to.be(true);
});
it('communicates X-Pack being unavailable', async () => {
const badRequestError = new Error('Bad request');
badRequestError.status = 400;
mockElasticsearchCluster.callWithInternalUser.returns(Promise.reject(badRequestError));
await xPackInfo.refreshNow();
expect(xPackInfo.isAvailable()).to.be(false);
expect(xPackInfo.isXpackUnavailable()).to.be(true);
expect(xPackInfo.license.isActive()).to.be(false);
expect(xPackInfo.unavailableReason()).to.be(
'X-Pack plugin is not installed on the [data] Elasticsearch cluster.'
);
});
it('correctly updates xpack info if Elasticsearch API fails.', async () => {
expect(xPackInfo.isAvailable()).to.be(true);
expect(xPackInfo.license.isActive()).to.be(true);
mockElasticsearchCluster.callWithInternalUser.returns(Promise.reject(new Error('Uh oh')));
await xPackInfo.refreshNow();
expect(xPackInfo.isAvailable()).to.be(false);
expect(xPackInfo.license.isActive()).to.be(false);
});
it('correctly updates xpack info when Elasticsearch API recovers after failure.', async () => {
expect(xPackInfo.isAvailable()).to.be(true);
expect(xPackInfo.license.isActive()).to.be(true);
expect(xPackInfo.unavailableReason()).to.be(undefined);
const randomError = new Error('Uh oh');
mockElasticsearchCluster.callWithInternalUser.returns(Promise.reject(randomError));
await xPackInfo.refreshNow();
expect(xPackInfo.isAvailable()).to.be(false);
expect(xPackInfo.license.isActive()).to.be(false);
expect(xPackInfo.unavailableReason()).to.be(randomError);
sinon.assert.calledWithExactly(
mockServer.log,
['license', 'warning', 'xpack'],
`License information from the X-Pack plugin could not be obtained from Elasticsearch` +
` for the [data] cluster. ${randomError}`
);
const badRequestError = new Error('Bad request');
badRequestError.status = 400;
mockElasticsearchCluster.callWithInternalUser.returns(Promise.reject(badRequestError));
await xPackInfo.refreshNow();
expect(xPackInfo.isAvailable()).to.be(false);
expect(xPackInfo.license.isActive()).to.be(false);
expect(xPackInfo.unavailableReason()).to.be(
'X-Pack plugin is not installed on the [data] Elasticsearch cluster.'
);
sinon.assert.calledWithExactly(
mockServer.log,
['license', 'warning', 'xpack'],
`License information from the X-Pack plugin could not be obtained from Elasticsearch` +
` for the [data] cluster. ${badRequestError}`
);
mockElasticsearchCluster.callWithInternalUser.returns(getMockXPackInfoAPIResponse());
await xPackInfo.refreshNow();
expect(xPackInfo.isAvailable()).to.be(true);
expect(xPackInfo.license.isActive()).to.be(true);
});
it('logs license status changes.', async () => {
sinon.assert.calledWithExactly(
mockServer.log,
['license', 'info', 'xpack'],
sinon.match('Imported license information from Elasticsearch for the [data] cluster: ' +
'mode: gold | status: active | expiry date: '
)
);
mockServer.log.resetHistory();
await xPackInfo.refreshNow();
// Response is still the same, so nothing should be logged.
sinon.assert.neverCalledWith(mockServer.log, ['license', 'info', 'xpack']);
// Change mode/status of the license and the change should be logged.
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ status: 'expired', mode: 'platinum' })
);
await xPackInfo.refreshNow();
sinon.assert.calledWithExactly(
mockServer.log,
['license', 'info', 'xpack'],
sinon.match('Imported changed license information from Elasticsearch for the [data] cluster: ' +
'mode: platinum | status: expired | expiry date: '
)
);
});
it('restarts the poller.', async () => {
mockElasticsearchCluster.callWithInternalUser.resetHistory();
sandbox.clock.tick(1499);
sinon.assert.notCalled(mockElasticsearchCluster.callWithInternalUser);
sandbox.clock.tick(1);
sinon.assert.calledOnce(mockElasticsearchCluster.callWithInternalUser);
// Exhaust micro-task queue, to make sure that `callWithInternalUser` is completed and
// new poller iteration is rescheduled.
await Promise.resolve();
sandbox.clock.tick(1500);
sinon.assert.calledTwice(mockElasticsearchCluster.callWithInternalUser);
// Exhaust micro-task queue, to make sure that `callWithInternalUser` is completed and
// new poller iteration is rescheduled.
await Promise.resolve();
sandbox.clock.tick(1499);
await xPackInfo.refreshNow();
mockElasticsearchCluster.callWithInternalUser.resetHistory();
// Since poller has been restarted, it should not be called now.
sandbox.clock.tick(1);
sinon.assert.notCalled(mockElasticsearchCluster.callWithInternalUser);
// Here it still shouldn't be called.
sandbox.clock.tick(1498);
sinon.assert.notCalled(mockElasticsearchCluster.callWithInternalUser);
sandbox.clock.tick(1);
sinon.assert.calledOnce(mockElasticsearchCluster.callWithInternalUser);
sinon.assert.calledOnce(refresh);
});
});
describe('license', () => {
let xPackInfo;
let license$;
beforeEach(async () => {
mockElasticsearchCluster.callWithInternalUser.returns(getMockXPackInfoAPIResponse());
xPackInfo = new XPackInfo(mockServer, { pollFrequencyInMillis: 1500 });
await xPackInfo.refreshNow();
license$ = new BehaviorSubject(createLicense());
xPackInfo = new XPackInfo(mockServer, {
licensing: {
license$,
refresh: () => null
}
});
});
it('getUid() shows license uid returned from the backend.', async () => {
it('getUid() shows license uid returned from the license$.', async () => {
expect(xPackInfo.license.getUid()).to.be('custom-uid');
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ uid: 'new-custom-uid' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ uid: 'new-custom-uid' }));
expect(xPackInfo.license.getUid()).to.be('new-custom-uid');
mockElasticsearchCluster.callWithInternalUser.returns(
Promise.reject(new Error('Uh oh'))
);
await xPackInfo.refreshNow();
license$.next(createLicense({ uid: undefined, error: 'error-reason' }));
expect(xPackInfo.license.getUid()).to.be(undefined);
});
@ -293,86 +108,46 @@ describe('XPackInfo', () => {
it('isActive() is based on the status returned from the backend.', async () => {
expect(xPackInfo.license.isActive()).to.be(true);
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ status: 'expired' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ status: 'expired' }));
expect(xPackInfo.license.isActive()).to.be(false);
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ status: 'some other value' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ status: 'some other value' }));
expect(xPackInfo.license.isActive()).to.be(false);
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ status: 'active' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ status: 'active' }));
expect(xPackInfo.license.isActive()).to.be(true);
mockElasticsearchCluster.callWithInternalUser.returns(
Promise.reject(new Error('Uh oh'))
);
await xPackInfo.refreshNow();
license$.next(createLicense({ status: undefined, error: 'error-reason' }));
expect(xPackInfo.license.isActive()).to.be(false);
});
it('getExpiryDateInMillis() is based on the value returned from the backend.', async () => {
expect(xPackInfo.license.getExpiryDateInMillis()).to.be(1286575200000);
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ expiry_date_in_millis: 10203040 })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ expiryDateInMillis: 10203040 }));
expect(xPackInfo.license.getExpiryDateInMillis()).to.be(10203040);
mockElasticsearchCluster.callWithInternalUser.returns(
Promise.reject(new Error('Uh oh'))
);
await xPackInfo.refreshNow();
license$.next(createLicense({ expiryDateInMillis: undefined, error: 'error-reason' }));
expect(xPackInfo.license.getExpiryDateInMillis()).to.be(undefined);
});
it('getType() is based on the value returned from the backend.', async () => {
expect(xPackInfo.license.getType()).to.be('gold');
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ type: 'basic' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ type: 'basic' }));
expect(xPackInfo.license.getType()).to.be('basic');
mockElasticsearchCluster.callWithInternalUser.returns(
Promise.reject(new Error('Uh oh'))
);
await xPackInfo.refreshNow();
license$.next(createLicense({ type: undefined, error: 'error-reason' }));
expect(xPackInfo.license.getType()).to.be(undefined);
});
it('isOneOf() correctly determines if current license is presented in the specified list.', async () => {
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ mode: 'gold' })
);
await xPackInfo.refreshNow();
expect(xPackInfo.license.isOneOf('gold')).to.be(true);
expect(xPackInfo.license.isOneOf(['gold', 'basic'])).to.be(true);
expect(xPackInfo.license.isOneOf(['platinum', 'basic'])).to.be(false);
expect(xPackInfo.license.isOneOf('standard')).to.be(false);
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ mode: 'basic' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ mode: 'basic' }));
expect(xPackInfo.license.isOneOf('basic')).to.be(true);
expect(xPackInfo.license.isOneOf(['gold', 'basic'])).to.be(true);
@ -383,18 +158,20 @@ describe('XPackInfo', () => {
describe('feature', () => {
let xPackInfo;
let license$;
beforeEach(async () => {
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({}, {
feature: {
available: false,
enabled: true
}
})
);
xPackInfo = new XPackInfo(mockServer, { pollFrequencyInMillis: 1500 });
await xPackInfo.refreshNow();
license$ = new BehaviorSubject(createLicense({}, {
feature: {
isAvailable: false,
isEnabled: true
}
}));
xPackInfo = new XPackInfo(mockServer, {
licensing: {
license$,
refresh: () => null
}
});
});
it('isAvailable() checks whether particular feature is available.', async () => {
@ -462,10 +239,7 @@ describe('XPackInfo', () => {
someAnotherCustomValue: 500100
});
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ type: 'platinum' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ type: 'platinum' }));
expect(xPackInfo.toJSON().features.security).to.eql({
isXPackInfo: true,
@ -520,10 +294,8 @@ describe('XPackInfo', () => {
someAnotherCustomValue: 500100
});
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ type: 'platinum' })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ type: 'platinum' }));
expect(securityFeature.getLicenseCheckResults()).to.eql({
isXPackInfo: true,
@ -539,9 +311,13 @@ describe('XPackInfo', () => {
});
it('getSignature() returns correct signature.', async () => {
mockElasticsearchCluster.callWithInternalUser.returns(getMockXPackInfoAPIResponse());
const xPackInfo = new XPackInfo(mockServer, { pollFrequencyInMillis: 1500 });
await xPackInfo.refreshNow();
const license$ = new BehaviorSubject(createLicense());
const xPackInfo = new XPackInfo(mockServer, {
licensing: {
license$,
refresh: () => null
}
});
expect(xPackInfo.getSignature()).to.be(getSignature({
license: {
@ -552,24 +328,21 @@ describe('XPackInfo', () => {
features: {}
}));
mockElasticsearchCluster.callWithInternalUser.returns(
getMockXPackInfoAPIResponse({ type: 'platinum', expiry_date_in_millis: nowDate.getTime() })
);
await xPackInfo.refreshNow();
license$.next(createLicense({ type: 'platinum', expiryDateInMillis: 20304050 }));
const expectedSignature = getSignature({
license: {
type: 'platinum',
isActive: true,
expiryDateInMillis: nowDate.getTime()
expiryDateInMillis: 20304050
},
features: {}
});
expect(xPackInfo.getSignature()).to.be(expectedSignature);
// Should stay the same after refresh if nothing changed.
await xPackInfo.refreshNow();
license$.next(createLicense({ type: 'platinum', expiryDateInMillis: 20304050 }));
expect(xPackInfo.getSignature()).to.be(expectedSignature);
});
});

View file

@ -16,12 +16,16 @@ import { XPackInfo } from './xpack_info';
* @param server {Object} The Kibana server object.
*/
export function setupXPackMain(server) {
const info = new XPackInfo(server, {
pollFrequencyInMillis: server.config().get('xpack.xpack_main.xpack_api_polling_frequency_millis')
});
const info = new XPackInfo(server, { licensing: server.newPlatform.setup.plugins.licensing });
server.expose('info', info);
server.expose('createXPackInfo', (options) => new XPackInfo(server, options));
server.expose('createXPackInfo', (options) => {
const client = server.newPlatform.setup.core.elasticsearch.createClient(options.clusterSource);
const monitoringLicensing = server.newPlatform.setup.plugins.licensing.createLicensePoller(client, options.pollFrequencyInMillis);
return new XPackInfo(server, { licensing: monitoringLicensing });
});
server.ext('onPreResponse', (request, h) => injectXPackInfoSignature(info, request, h));
const { registerFeature, getFeatures } = server.newPlatform.setup.plugins.features;

View file

@ -1,37 +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 { Server } from 'hapi';
import { XPackInfoLicense } from './xpack_info_license';
interface XPackFeature {
isAvailable(): boolean;
isEnabled(): boolean;
registerLicenseCheckResultsGenerator(generator: (xpackInfo: XPackInfo) => void): void;
getLicenseCheckResults(): any;
}
export interface XPackInfoOptions {
clusterSource?: string;
pollFrequencyInMillis: number;
}
export declare class XPackInfo {
public license: XPackInfoLicense;
constructor(server: Server, options: XPackInfoOptions);
public isAvailable(): boolean;
public isXpackUnavailable(): boolean;
public unavailableReason(): string | Error;
public onLicenseInfoChange(handler: () => void): void;
public refreshNow(): Promise<this>;
public feature(name: string): XPackFeature;
public getSignature(): string;
public toJSON(): any;
}

View file

@ -1,308 +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 { createHash } from 'crypto';
import moment from 'moment';
import { get, has } from 'lodash';
import { Poller } from '../../../../common/poller';
import { XPackInfoLicense } from './xpack_info_license';
/**
* A helper that provides a convenient way to access XPack Info returned by Elasticsearch.
*/
export class XPackInfo {
/**
* XPack License object.
* @type {XPackInfoLicense}
* @private
*/
_license;
/**
* Feature name <-> feature license check generator function mapping.
* @type {Map<string, Function>}
* @private
*/
_featureLicenseCheckResultsGenerators = new Map();
/**
* Set of listener functions that will be called whenever the license
* info changes
* @type {Set<Function>}
*/
_licenseInfoChangedListeners = new Set();
/**
* Cache that may contain last xpack info API response or error, json representation
* of xpack info and xpack info signature.
* @type {{response: Object|undefined, error: Object|undefined, json: Object|undefined, signature: string|undefined}}
* @private
*/
_cache = {};
/**
* XPack info poller.
* @type {Poller}
* @private
*/
_poller;
/**
* XPack License instance.
* @returns {XPackInfoLicense}
*/
get license() {
return this._license;
}
/**
* Constructs XPack info object.
* @param {Hapi.Server} server HapiJS server instance.
* @param {Object} options
* @property {string} [options.clusterSource] Type of the cluster that should be used
* to fetch XPack info (data, monitoring etc.). If not provided, `data` is used.
* @property {number} options.pollFrequencyInMillis Polling interval used to automatically
* refresh XPack Info by the internal poller.
*/
constructor(server, { clusterSource = 'data', pollFrequencyInMillis }) {
this._log = server.log.bind(server);
this._cluster = server.plugins.elasticsearch.getCluster(clusterSource);
this._clusterSource = clusterSource;
// Create a poller that will be (re)started inside of the `refreshNow` call.
this._poller = new Poller({
functionToPoll: () => this.refreshNow(),
trailing: true,
pollFrequencyInMillis,
continuePollingOnError: true
});
server.events.on('stop', () => {
this._poller.stop();
});
this._license = new XPackInfoLicense(
() => this._cache.response && this._cache.response.license
);
}
/**
* Checks whether XPack info is available.
* @returns {boolean}
*/
isAvailable() {
return !!this._cache.response && !!this._cache.response.license;
}
/**
* Checks whether ES was available
* @returns {boolean}
*/
isXpackUnavailable() {
return this._cache.error instanceof Error && this._cache.error.status === 400;
}
/**
* If present, describes the reason why XPack info is not available.
* @returns {Error|string}
*/
unavailableReason() {
if (!this._cache.error && this._cache.response && !this._cache.response.license) {
return `[${this._clusterSource}] Elasticsearch cluster did not respond with license information.`;
}
if (this.isXpackUnavailable()) {
return `X-Pack plugin is not installed on the [${this._clusterSource}] Elasticsearch cluster.`;
}
return this._cache.error;
}
onLicenseInfoChange(handler) {
this._licenseInfoChangedListeners.add(handler);
}
/**
* Queries server to get the updated XPack info.
* @returns {Promise.<XPackInfo>}
*/
async refreshNow() {
this._log(['license', 'debug', 'xpack'], (
`Calling [${this._clusterSource}] Elasticsearch _xpack API. Polling frequency: ${this._poller.getPollFrequency()}`
));
// We can reset polling timer since we force refresh here.
this._poller.stop();
try {
const response = await this._cluster.callWithInternalUser('transport.request', {
method: 'GET',
path: '/_xpack'
});
const licenseInfoChanged = this._hasLicenseInfoChanged(response);
if (licenseInfoChanged) {
const licenseInfoParts = [
`mode: ${get(response, 'license.mode')}`,
`status: ${get(response, 'license.status')}`,
];
if (has(response, 'license.expiry_date_in_millis')) {
const expiryDate = moment(response.license.expiry_date_in_millis, 'x').format();
licenseInfoParts.push(`expiry date: ${expiryDate}`);
}
const licenseInfo = licenseInfoParts.join(' | ');
this._log(
['license', 'info', 'xpack'],
`Imported ${this._cache.response ? 'changed ' : ''}license information` +
` from Elasticsearch for the [${this._clusterSource}] cluster: ${licenseInfo}`
);
}
this._cache = { response };
if (licenseInfoChanged) {
// call license info changed listeners
for (const listener of this._licenseInfoChangedListeners) {
listener();
}
}
} catch(error) {
this._log(
['license', 'warning', 'xpack'],
`License information from the X-Pack plugin could not be obtained from Elasticsearch` +
` for the [${this._clusterSource}] cluster. ${error}`
);
this._cache = { error };
}
this._poller.start();
return this;
}
/**
* Returns a wrapper around XPack info that gives an access to the properties of
* the specific feature.
* @param {string} name Name of the feature to get a wrapper for.
* @returns {Object}
*/
feature(name) {
return {
/**
* Checks whether feature is available (permitted by the current license).
* @returns {boolean}
*/
isAvailable: () => {
return !!get(this._cache.response, `features.${name}.available`);
},
/**
* Checks whether feature is enabled (not disabled by the configuration specifically).
* @returns {boolean}
*/
isEnabled: () => {
return !!get(this._cache.response, `features.${name}.enabled`);
},
/**
* Registers a `generator` function that will be called with XPackInfo instance as
* argument whenever XPack info changes. Whatever `generator` returns will be stored
* in XPackInfo JSON representation and can be accessed with `getLicenseCheckResults`.
* @param {Function} generator Function to call whenever XPackInfo changes.
*/
registerLicenseCheckResultsGenerator: (generator) => {
this._featureLicenseCheckResultsGenerators.set(name, generator);
// Since JSON representation and signature are cached we should invalidate them to
// include results from newly registered generator when they are requested.
this._cache.json = undefined;
this._cache.signature = undefined;
},
/**
* Returns license check results that were previously produced by the `generator` function.
* @returns {Object}
*/
getLicenseCheckResults: () => this.toJSON().features[name]
};
}
/**
* Extracts string md5 hash from the stringified version of license JSON representation.
* @returns {string}
*/
getSignature() {
if (this._cache.signature) {
return this._cache.signature;
}
this._cache.signature = createHash('md5')
.update(JSON.stringify(this.toJSON()))
.digest('hex');
return this._cache.signature;
}
/**
* Returns JSON representation of the license object that is suitable for serialization.
* @returns {Object}
*/
toJSON() {
if (this._cache.json) {
return this._cache.json;
}
this._cache.json = {
license: {
type: this.license.getType(),
isActive: this.license.isActive(),
expiryDateInMillis: this.license.getExpiryDateInMillis()
},
features: {}
};
// Set response elements specific to each feature. To do this,
// call the license check results generator for each feature, passing them
// the xpack info object
for (const [feature, licenseChecker] of this._featureLicenseCheckResultsGenerators) {
// return value expected to be a dictionary object.
this._cache.json.features[feature] = licenseChecker(this);
}
return this._cache.json;
}
/**
* Checks whether license within specified response differs from the current license.
* Comparison is based on license mode, status and expiration date.
* @param {Object} response xPack info response object returned from the backend.
* @returns {boolean} True if license within specified response object differs from
* the one we already have.
* @private
*/
_hasLicenseInfoChanged(response) {
const newLicense = get(response, 'license') || {};
const cachedLicense = get(this._cache.response, 'license') || {};
if (newLicense.mode !== cachedLicense.mode) {
return true;
}
if (newLicense.status !== cachedLicense.status) {
return true;
}
return newLicense.expiry_date_in_millis !== cachedLicense.expiry_date_in_millis;
}
}

View file

@ -0,0 +1,240 @@
/*
* 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 { createHash } from 'crypto';
import { Legacy } from 'kibana';
import { XPackInfoLicense } from './xpack_info_license';
import { LicensingPluginSetup, ILicense } from '../../../../../plugins/licensing/server';
export interface XPackInfoOptions {
clusterSource?: string;
pollFrequencyInMillis: number;
}
type LicenseGeneratorCheck = (xpackInfo: XPackInfo) => any;
export interface XPackFeature {
isAvailable(): boolean;
isEnabled(): boolean;
registerLicenseCheckResultsGenerator(generator: LicenseGeneratorCheck): void;
getLicenseCheckResults(): any;
}
interface Deps {
licensing: LicensingPluginSetup;
}
/**
* A helper that provides a convenient way to access XPack Info returned by Elasticsearch.
*/
export class XPackInfo {
/**
* XPack License object.
* @type {XPackInfoLicense}
* @private
*/
_license: XPackInfoLicense;
/**
* Feature name <-> feature license check generator function mapping.
* @type {Map<string, Function>}
* @private
*/
_featureLicenseCheckResultsGenerators = new Map<string, LicenseGeneratorCheck>();
/**
* Set of listener functions that will be called whenever the license
* info changes
* @type {Set<Function>}
*/
_licenseInfoChangedListeners = new Set<() => void>();
/**
* Cache that may contain last xpack info API response or error, json representation
* of xpack info and xpack info signature.
* @type {{response: Object|undefined, error: Object|undefined, json: Object|undefined, signature: string|undefined}}
* @private
*/
private _cache: {
license?: ILicense;
error?: string;
json?: Record<string, any>;
signature?: string;
};
/**
* XPack License instance.
* @returns {XPackInfoLicense}
*/
public get license() {
return this._license;
}
private readonly licensingPlugin: LicensingPluginSetup;
/**
* Constructs XPack info object.
* @param {Hapi.Server} server HapiJS server instance.
*/
constructor(server: Legacy.Server, deps: Deps) {
if (!deps.licensing) {
throw new Error('XPackInfo requires enabled Licensing plugin');
}
this.licensingPlugin = deps.licensing;
this._cache = {};
this.licensingPlugin.license$.subscribe((license: ILicense) => {
if (license.isActive) {
this._cache = {
license,
error: undefined,
};
} else {
this._cache = {
license,
error: license.error,
};
}
});
this._license = new XPackInfoLicense(() => this._cache.license);
}
/**
* Checks whether XPack info is available.
* @returns {boolean}
*/
isAvailable() {
return Boolean(this._cache.license?.isAvailable);
}
/**
* Checks whether ES was available
* @returns {boolean}
*/
isXpackUnavailable() {
return (
this._cache.error &&
this._cache.error === 'X-Pack plugin is not installed on the Elasticsearch cluster.'
);
}
/**
* If present, describes the reason why XPack info is not available.
* @returns {Error|string}
*/
unavailableReason() {
return this._cache.license?.getUnavailableReason();
}
onLicenseInfoChange(handler: () => void) {
this._licenseInfoChangedListeners.add(handler);
}
/**
* Queries server to get the updated XPack info.
* @returns {Promise.<XPackInfo>}
*/
async refreshNow() {
await this.licensingPlugin.refresh();
return this;
}
/**
* Returns a wrapper around XPack info that gives an access to the properties of
* the specific feature.
* @param {string} name Name of the feature to get a wrapper for.
* @returns {Object}
*/
feature(name: string): XPackFeature {
return {
/**
* Checks whether feature is available (permitted by the current license).
* @returns {boolean}
*/
isAvailable: () => {
return Boolean(this._cache.license?.getFeature(name).isAvailable);
},
/**
* Checks whether feature is enabled (not disabled by the configuration specifically).
* @returns {boolean}
*/
isEnabled: () => {
return Boolean(this._cache.license?.getFeature(name).isEnabled);
},
/**
* Registers a `generator` function that will be called with XPackInfo instance as
* argument whenever XPack info changes. Whatever `generator` returns will be stored
* in XPackInfo JSON representation and can be accessed with `getLicenseCheckResults`.
* @param {Function} generator Function to call whenever XPackInfo changes.
*/
registerLicenseCheckResultsGenerator: (generator: LicenseGeneratorCheck) => {
this._featureLicenseCheckResultsGenerators.set(name, generator);
// Since JSON representation and signature are cached we should invalidate them to
// include results from newly registered generator when they are requested.
this._cache.json = undefined;
this._cache.signature = undefined;
},
/**
* Returns license check results that were previously produced by the `generator` function.
* @returns {Object}
*/
getLicenseCheckResults: () => this.toJSON().features[name],
};
}
/**
* Extracts string md5 hash from the stringified version of license JSON representation.
* @returns {string}
*/
getSignature() {
if (this._cache.signature) {
return this._cache.signature;
}
this._cache.signature = createHash('md5')
.update(JSON.stringify(this.toJSON()))
.digest('hex');
return this._cache.signature;
}
/**
* Returns JSON representation of the license object that is suitable for serialization.
* @returns {Object}
*/
toJSON() {
if (this._cache.json) {
return this._cache.json;
}
this._cache.json = {
license: {
type: this.license.getType(),
isActive: this.license.isActive(),
expiryDateInMillis: this.license.getExpiryDateInMillis(),
},
features: {},
};
// Set response elements specific to each feature. To do this,
// call the license check results generator for each feature, passing them
// the xpack info object
for (const [feature, licenseChecker] of this._featureLicenseCheckResultsGenerators) {
// return value expected to be a dictionary object.
this._cache.json.features[feature] = licenseChecker(this);
}
return this._cache.json;
}
}

View file

@ -1,21 +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.
*/
type LicenseType = 'oss' | 'basic' | 'trial' | 'standard' | 'basic' | 'gold' | 'platinum';
export declare class XPackInfoLicense {
constructor(getRawLicense: () => any);
public getUid(): string | undefined;
public isActive(): boolean;
public getExpiryDateInMillis(): number | undefined;
public isOneOf(candidateLicenses: string[]): boolean;
public getType(): LicenseType | undefined;
public getMode(): string | undefined;
public isActiveLicense(typeChecker: (mode: string) => boolean): boolean;
public isBasic(): boolean;
public isNotBasic(): boolean;
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { licensingMock } from '../../../../../plugins/licensing/server/licensing.mock';
import { XPackInfoLicense } from './xpack_info_license';
function getXPackInfoLicense(getRawLicense) {
@ -24,7 +25,7 @@ describe('XPackInfoLicense', () => {
test('getUid returns uid field', () => {
const uid = 'abc123';
getRawLicense.mockReturnValue({ uid });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { uid } }));
expect(xpackInfoLicense.getUid()).toBe(uid);
expect(getRawLicense).toHaveBeenCalledTimes(1);
@ -33,14 +34,14 @@ describe('XPackInfoLicense', () => {
});
test('isActive returns true if status is active', () => {
getRawLicense.mockReturnValue({ status: 'active' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'active' } }));
expect(xpackInfoLicense.isActive()).toBe(true);
expect(getRawLicense).toHaveBeenCalledTimes(1);
});
test('isActive returns false if status is not active', () => {
getRawLicense.mockReturnValue({ status: 'aCtIvE' }); // needs to match exactly
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'aCtIvE' } })); // needs to match exactly
expect(xpackInfoLicense.isActive()).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(1);
@ -49,7 +50,7 @@ describe('XPackInfoLicense', () => {
});
test('getExpiryDateInMillis returns expiry_date_in_millis', () => {
getRawLicense.mockReturnValue({ expiry_date_in_millis: 123 });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { expiryDateInMillis: 123 } }));
expect(xpackInfoLicense.getExpiryDateInMillis()).toBe(123);
expect(getRawLicense).toHaveBeenCalledTimes(1);
@ -58,7 +59,7 @@ describe('XPackInfoLicense', () => {
});
test('isOneOf returns true of the mode includes one of the types', () => {
getRawLicense.mockReturnValue({ mode: 'platinum' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { mode: 'platinum' } }));
expect(xpackInfoLicense.isOneOf('platinum')).toBe(true);
expect(getRawLicense).toHaveBeenCalledTimes(1);
@ -78,12 +79,12 @@ describe('XPackInfoLicense', () => {
});
test('getType returns the type', () => {
getRawLicense.mockReturnValue({ type: 'basic' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { type: 'basic' } }));
expect(xpackInfoLicense.getType()).toBe('basic');
expect(getRawLicense).toHaveBeenCalledTimes(1);
getRawLicense.mockReturnValue({ type: 'gold' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { type: 'gold' } }));
expect(xpackInfoLicense.getType()).toBe('gold');
expect(getRawLicense).toHaveBeenCalledTimes(2);
@ -92,12 +93,12 @@ describe('XPackInfoLicense', () => {
});
test('getMode returns the mode', () => {
getRawLicense.mockReturnValue({ mode: 'basic' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { mode: 'basic' } }));
expect(xpackInfoLicense.getMode()).toBe('basic');
expect(getRawLicense).toHaveBeenCalledTimes(1);
getRawLicense.mockReturnValue({ mode: 'gold' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { mode: 'gold' } }));
expect(xpackInfoLicense.getMode()).toBe('gold');
expect(getRawLicense).toHaveBeenCalledTimes(2);
@ -108,22 +109,22 @@ describe('XPackInfoLicense', () => {
test('isActiveLicense returns the true if active and typeChecker matches', () => {
const expectAbc123 = type => type === 'abc123';
getRawLicense.mockReturnValue({ status: 'active', mode: 'abc123' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'active', mode: 'abc123' } }));
expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(true);
expect(getRawLicense).toHaveBeenCalledTimes(1);
getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'abc123' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'NOTactive', mode: 'abc123' } }));
expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(2);
getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'NOTabc123' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'NOTactive', mode: 'NOTabc123' } }));
expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(3);
getRawLicense.mockReturnValue({ status: 'active', mode: 'NOTabc123' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'active', mode: 'NOTabc123' } }));
expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(4);
@ -132,22 +133,22 @@ describe('XPackInfoLicense', () => {
});
test('isBasic returns the true if active and basic', () => {
getRawLicense.mockReturnValue({ status: 'active', mode: 'basic' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'active', mode: 'basic' } }));
expect(xpackInfoLicense.isBasic()).toBe(true);
expect(getRawLicense).toHaveBeenCalledTimes(1);
getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'gold' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'NOTactive', mode: 'gold' } }));
expect(xpackInfoLicense.isBasic()).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(2);
getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'trial' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'NOTactive', mode: 'trial' } }));
expect(xpackInfoLicense.isBasic()).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(3);
getRawLicense.mockReturnValue({ status: 'active', mode: 'platinum' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'active', mode: 'platinum' } }));
expect(xpackInfoLicense.isBasic()).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(4);
@ -157,22 +158,22 @@ describe('XPackInfoLicense', () => {
test('isNotBasic returns the true if active and not basic', () => {
getRawLicense.mockReturnValue({ status: 'active', mode: 'platinum' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'active', mode: 'platinum' } }));
expect(xpackInfoLicense.isNotBasic()).toBe(true);
expect(getRawLicense).toHaveBeenCalledTimes(1);
getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'gold' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'NOTactive', mode: 'gold' } }));
expect(xpackInfoLicense.isNotBasic()).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(2);
getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'trial' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'NOTactive', mode: 'trial' } }));
expect(xpackInfoLicense.isNotBasic()).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(3);
getRawLicense.mockReturnValue({ status: 'active', mode: 'basic' });
getRawLicense.mockReturnValue(licensingMock.createLicense({ license: { status: 'active', mode: 'basic' } }));
expect(xpackInfoLicense.isNotBasic()).toBe(false);
expect(getRawLicense).toHaveBeenCalledTimes(4);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
import { ILicense } from '../../../../../plugins/licensing/server';
/**
* "View" for XPack Info license information.
@ -15,9 +15,9 @@ export class XPackInfoLicense {
* @type {Function}
* @private
*/
_getRawLicense = null;
_getRawLicense: () => ILicense | undefined;
constructor(getRawLicense) {
constructor(getRawLicense: () => ILicense | undefined) {
this._getRawLicense = getRawLicense;
}
@ -26,7 +26,7 @@ export class XPackInfoLicense {
* @returns {string|undefined}
*/
getUid() {
return get(this._getRawLicense(), 'uid');
return this._getRawLicense()?.uid;
}
/**
@ -34,7 +34,7 @@ export class XPackInfoLicense {
* @returns {boolean}
*/
isActive() {
return get(this._getRawLicense(), 'status') === 'active';
return Boolean(this._getRawLicense()?.isActive);
}
/**
@ -45,7 +45,7 @@ export class XPackInfoLicense {
* @returns {number|undefined}
*/
getExpiryDateInMillis() {
return get(this._getRawLicense(), 'expiry_date_in_millis');
return this._getRawLicense()?.expiryDateInMillis;
}
/**
@ -53,12 +53,10 @@ export class XPackInfoLicense {
* @param {String} candidateLicenses List of the licenses to check against.
* @returns {boolean}
*/
isOneOf(candidateLicenses) {
if (!Array.isArray(candidateLicenses)) {
candidateLicenses = [candidateLicenses];
}
return candidateLicenses.includes(get(this._getRawLicense(), 'mode'));
isOneOf(candidateLicenses: string | string[]) {
const candidates = Array.isArray(candidateLicenses) ? candidateLicenses : [candidateLicenses];
const mode = this._getRawLicense()?.mode;
return Boolean(mode && candidates.includes(mode));
}
/**
@ -66,7 +64,7 @@ export class XPackInfoLicense {
* @returns {string|undefined}
*/
getType() {
return get(this._getRawLicense(), 'type');
return this._getRawLicense()?.type;
}
/**
@ -74,7 +72,7 @@ export class XPackInfoLicense {
* @returns {string|undefined}
*/
getMode() {
return get(this._getRawLicense(), 'mode');
return this._getRawLicense()?.mode;
}
/**
@ -83,10 +81,10 @@ export class XPackInfoLicense {
* @param {Function} typeChecker The license type checker.
* @returns {boolean}
*/
isActiveLicense(typeChecker) {
isActiveLicense(typeChecker: (mode: string) => boolean) {
const license = this._getRawLicense();
return get(license, 'status') === 'active' && typeChecker(get(license, 'mode'));
return Boolean(license?.isActive && typeChecker(license.mode as any));
}
/**

View file

@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n';
import { CoreSetup, CoreStart } from 'kibana/public';
import { Plugin } from 'src/core/public';
import { toggleNavLink } from './services/toggle_nav_link';
import { LicensingPluginSetup } from '../../licensing/common/types';
import { LicensingPluginSetup } from '../../licensing/public';
import { checkLicense } from '../common/check_license';
import {
FeatureCatalogueCategory,

View file

@ -5,7 +5,7 @@
*/
import { Plugin, CoreSetup } from 'src/core/server';
import { LicensingPluginSetup } from '../../licensing/common/types';
import { LicensingPluginSetup } from '../../licensing/server';
import { LicenseState } from './lib/license_state';
import { registerSearchRoute } from './routes/search';
import { registerExploreRoute } from './routes/explore';

View file

@ -13,6 +13,7 @@ function license({ error, ...customLicense }: { error?: string; [key: string]: a
uid: 'uid-000000001234',
status: 'active',
type: 'basic',
mode: 'basic',
expiryDateInMillis: 1000,
};

View file

@ -6,7 +6,7 @@
import { License } from './license';
import { LICENSE_CHECK_STATE } from './types';
import { licenseMock } from './license.mock';
import { licenseMock } from './licensing.mock';
describe('License', () => {
const basicLicense = licenseMock.create();

View file

@ -33,6 +33,7 @@ export class License implements ILicense {
public readonly status?: LicenseStatus;
public readonly expiryDateInMillis?: number;
public readonly type?: LicenseType;
public readonly mode?: LicenseType;
public readonly signature: string;
/**
@ -65,6 +66,7 @@ export class License implements ILicense {
this.status = license.status;
this.expiryDateInMillis = license.expiryDateInMillis;
this.type = license.type;
this.mode = license.mode;
}
this.isActive = this.status === 'active';

View file

@ -9,7 +9,7 @@ import { take, toArray } from 'rxjs/operators';
import { ILicense, LicenseType } from './types';
import { createLicenseUpdate } from './license_update';
import { licenseMock } from './license.mock';
import { licenseMock } from './licensing.mock';
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const stop$ = new Subject();

View file

@ -19,6 +19,7 @@ function createLicense({
uid: 'uid-000000001234',
status: 'active',
type: 'basic',
mode: 'basic',
expiryDateInMillis: 5000,
};

View file

@ -3,7 +3,6 @@
* 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';
export enum LICENSE_CHECK_STATE {
Unavailable = 'UNAVAILABLE',
@ -57,6 +56,11 @@ export interface PublicLicense {
* The license type, being usually one of basic, standard, gold, platinum, or trial.
*/
type: LicenseType;
/**
* The license type, being usually one of basic, standard, gold, platinum, or trial.
* @deprecated use 'type' instead
*/
mode: LicenseType;
}
/**
@ -119,6 +123,12 @@ export interface ILicense {
*/
type?: LicenseType;
/**
* The license type, being usually one of basic, standard, gold, platinum, or trial.
* @deprecated use 'type' instead.
*/
mode?: LicenseType;
/**
* Signature of the license content.
*/
@ -173,15 +183,3 @@ export interface ILicense {
*/
getFeature(name: string): LicenseFeature;
}
/** @public */
export interface LicensingPluginSetup {
/**
* Steam of licensing information {@link ILicense}.
*/
license$: Observable<ILicense>;
/**
* Triggers licensing information re-fetch.
*/
refresh(): Promise<ILicense>;
}

View file

@ -8,4 +8,5 @@ import { PluginInitializerContext } from 'src/core/public';
import { LicensingPlugin } from './plugin';
export * from '../common/types';
export * from './types';
export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context);

View file

@ -0,0 +1,24 @@
/*
* 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 } from 'rxjs';
import { LicensingPluginSetup } from './types';
import { licenseMock } from '../common/licensing.mock';
const createSetupMock = () => {
const license = licenseMock.create();
const mock: jest.Mocked<LicensingPluginSetup> = {
license$: new BehaviorSubject(license),
refresh: jest.fn(),
};
mock.refresh.mockResolvedValue(license);
return mock;
};
export const licensingMock = {
createSetup: createSetupMock,
createLicense: licenseMock.create,
};

View file

@ -11,12 +11,10 @@ import { LicenseType } from '../common/types';
import { LicensingPlugin, licensingSessionStorageKey } from './plugin';
import { License } from '../common/license';
import { licenseMock } from '../common/license.mock';
import { licenseMock } from '../common/licensing.mock';
import { coreMock } from '../../../../src/core/public/mocks';
import { HttpInterceptor } from 'src/core/public';
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
describe('licensing plugin', () => {
let plugin: LicensingPlugin;
@ -34,15 +32,7 @@ describe('licensing plugin', () => {
const coreSetup = coreMock.createSetup();
const firstLicense = licenseMock.create({ license: { uid: 'first', type: 'basic' } });
const secondLicense = licenseMock.create({ license: { uid: 'second', type: 'gold' } });
coreSetup.http.get
.mockImplementationOnce(async () => {
await delay(100);
return firstLicense;
})
.mockImplementationOnce(async () => {
await delay(100);
return secondLicense;
});
coreSetup.http.get.mockResolvedValueOnce(firstLicense).mockResolvedValueOnce(secondLicense);
const { license$, refresh } = await plugin.setup(coreSetup);
@ -147,7 +137,7 @@ describe('licensing plugin', () => {
expect(sessionStorage.setItem.mock.calls[0][0]).toBe(licensingSessionStorageKey);
expect(sessionStorage.setItem.mock.calls[0][1]).toMatchInlineSnapshot(
`"{\\"license\\":{\\"uid\\":\\"fresh\\",\\"status\\":\\"active\\",\\"type\\":\\"basic\\",\\"expiryDateInMillis\\":5000},\\"features\\":{\\"ccr\\":{\\"isEnabled\\":true,\\"isAvailable\\":true},\\"ml\\":{\\"isEnabled\\":false,\\"isAvailable\\":true}},\\"signature\\":\\"xxxxxxxxx\\"}"`
`"{\\"license\\":{\\"uid\\":\\"fresh\\",\\"status\\":\\"active\\",\\"type\\":\\"basic\\",\\"mode\\":\\"basic\\",\\"expiryDateInMillis\\":5000},\\"features\\":{\\"ccr\\":{\\"isEnabled\\":true,\\"isAvailable\\":true},\\"ml\\":{\\"isEnabled\\":false,\\"isAvailable\\":true}},\\"signature\\":\\"xxxxxxxxx\\"}"`
);
const saved = JSON.parse(sessionStorage.setItem.mock.calls[0][1]);

View file

@ -7,7 +7,8 @@ import { Subject, Subscription } from 'rxjs';
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
import { ILicense, LicensingPluginSetup } from '../common/types';
import { ILicense } from '../common/types';
import { LicensingPluginSetup } from './types';
import { createLicenseUpdate } from '../common/license_update';
import { License } from '../common/license';
import { mountExpiredBanner } from './expired_banner';

View file

@ -0,0 +1,20 @@
/*
* 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 { ILicense } from '../common/types';
/** @public */
export interface LicensingPluginSetup {
/**
* Steam of licensing information {@link ILicense}.
*/
license$: Observable<ILicense>;
/**
* Triggers licensing information re-fetch.
*/
refresh(): Promise<ILicense>;
}

View file

@ -10,4 +10,5 @@ import { LicensingPlugin } from './plugin';
export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context);
export * from '../common/types';
export * from './types';
export { config } from './licensing_config';

View file

@ -0,0 +1,29 @@
/*
* 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 } from 'rxjs';
import { LicensingPluginSetup } from './types';
import { licenseMock } from '../common/licensing.mock';
const createSetupMock = () => {
const license = licenseMock.create();
const mock: jest.Mocked<LicensingPluginSetup> = {
license$: new BehaviorSubject(license),
refresh: jest.fn(),
createLicensePoller: jest.fn(),
};
mock.refresh.mockResolvedValue(license);
mock.createLicensePoller.mockReturnValue({
license$: mock.license$,
refresh: mock.refresh,
});
return mock;
};
export const licensingMock = {
createSetup: createSetupMock,
createLicense: licenseMock.create,
};

View file

@ -5,11 +5,22 @@
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from 'kibana/server';
export const config = {
const configSchema = schema.object({
api_polling_frequency: schema.duration({ defaultValue: '30s' }),
});
export type LicenseConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<LicenseConfigType> = {
schema: schema.object({
pollingFrequency: schema.duration({ defaultValue: '30s' }),
api_polling_frequency: schema.duration({ defaultValue: '30s' }),
}),
deprecations: ({ renameFromRoot }) => [
renameFromRoot(
'xpack.xpack_main.xpack_api_polling_frequency_millis',
'xpack.licensing.api_polling_frequency'
),
],
};
export type LicenseConfigType = TypeOf<typeof config.schema>;

View file

@ -5,7 +5,7 @@
*/
import { BehaviorSubject } from 'rxjs';
import { licenseMock } from '../common/license.mock';
import { licenseMock } from '../common/licensing.mock';
import { createRouteHandlerContext } from './licensing_route_handler_context';

View file

@ -0,0 +1,7 @@
/*
* 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 * from './licensing.mock';

View file

@ -6,7 +6,7 @@
import { BehaviorSubject } from 'rxjs';
import { createOnPreResponseHandler } from './on_pre_response_handler';
import { httpServiceMock, httpServerMock } from '../../../../src/core/server/mocks';
import { licenseMock } from '../common/license.mock';
import { licenseMock } from '../common/licensing.mock';
describe('createOnPreResponseHandler', () => {
it('sets license.signature header immediately for non-error responses', async () => {

View file

@ -21,11 +21,11 @@ function buildRawLicense(options: Partial<RawLicense> = {}): RawLicense {
uid: 'uid-000000001234',
status: 'active',
type: 'basic',
mode: 'basic',
expiry_date_in_millis: 1000,
};
return Object.assign(defaultRawLicense, options);
}
const pollingFrequency = moment.duration(100);
const flushPromises = (ms = 50) => new Promise(res => setTimeout(res, ms));
@ -37,7 +37,7 @@ describe('licensing plugin', () => {
beforeEach(() => {
pluginInitContextMock = coreMock.createPluginInitializerContext({
pollingFrequency,
api_polling_frequency: moment.duration(100),
});
plugin = new LicensingPlugin(pluginInitContextMock);
});
@ -200,7 +200,7 @@ describe('licensing plugin', () => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
// disable polling mechanism
pollingFrequency: moment.duration(50000),
api_polling_frequency: moment.duration(50000),
})
);
const dataClient = elasticsearchServiceMock.createClusterClient();
@ -222,13 +222,88 @@ describe('licensing plugin', () => {
});
});
describe('#createLicensePoller', () => {
let plugin: LicensingPlugin;
afterEach(async () => {
await plugin.stop();
});
it(`creates a poller fetching license from passed 'clusterClient' every 'api_polling_frequency' ms`, async () => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(50000),
})
);
const dataClient = elasticsearchServiceMock.createClusterClient();
dataClient.callAsInternalUser.mockResolvedValue({
license: buildRawLicense(),
features: {},
});
const coreSetup = coreMock.createSetup();
coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient);
const { createLicensePoller, license$ } = await plugin.setup(coreSetup);
const customClient = elasticsearchServiceMock.createClusterClient();
customClient.callAsInternalUser.mockResolvedValue({
license: buildRawLicense({ type: 'gold' }),
features: {},
});
const customPollingFrequency = 100;
const { license$: customLicense$ } = createLicensePoller(
customClient,
customPollingFrequency
);
expect(customClient.callAsInternalUser).toHaveBeenCalledTimes(0);
const customLicense = await customLicense$.pipe(take(1)).toPromise();
expect(customClient.callAsInternalUser).toHaveBeenCalledTimes(1);
await flushPromises(customPollingFrequency * 1.5);
expect(customClient.callAsInternalUser).toHaveBeenCalledTimes(2);
expect(customLicense.isAvailable).toBe(true);
expect(customLicense.type).toBe('gold');
expect(await license$.pipe(take(1)).toPromise()).not.toBe(customLicense);
});
it('creates a poller with a manual refresh control', async () => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(100),
})
);
const coreSetup = coreMock.createSetup();
const { createLicensePoller } = await plugin.setup(coreSetup);
const customClient = elasticsearchServiceMock.createClusterClient();
customClient.callAsInternalUser.mockResolvedValue({
license: buildRawLicense({ type: 'gold' }),
features: {},
});
const { license$, refresh } = createLicensePoller(customClient, 10000);
expect(customClient.callAsInternalUser).toHaveBeenCalledTimes(0);
await refresh();
expect(customClient.callAsInternalUser).toHaveBeenCalledTimes(1);
const license = await license$.pipe(take(1)).toPromise();
expect(license.type).toBe('gold');
});
});
describe('extends core contexts', () => {
let plugin: LicensingPlugin;
beforeEach(() => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
pollingFrequency,
api_polling_frequency: moment.duration(100),
})
);
});
@ -257,7 +332,9 @@ describe('licensing plugin', () => {
let plugin: LicensingPlugin;
beforeEach(() => {
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext({ pollingFrequency }));
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({ api_polling_frequency: moment.duration(100) })
);
});
afterEach(async () => {
@ -278,7 +355,7 @@ describe('licensing plugin', () => {
it('stops polling', async () => {
const plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
pollingFrequency,
api_polling_frequency: moment.duration(100),
})
);
const coreSetup = coreMock.createSetup();

View file

@ -6,7 +6,7 @@
import { Observable, Subject, Subscription, timer } from 'rxjs';
import { take } from 'rxjs/operators';
import moment, { Duration } from 'moment';
import moment from 'moment';
import { createHash } from 'crypto';
import stringify from 'json-stable-stringify';
@ -19,7 +19,8 @@ import {
IClusterClient,
} from 'src/core/server';
import { ILicense, LicensingPluginSetup, PublicLicense, PublicFeatures } from '../common/types';
import { ILicense, PublicLicense, PublicFeatures } from '../common/types';
import { LicensingPluginSetup } from './types';
import { License } from '../common/license';
import { createLicenseUpdate } from '../common/license_update';
@ -34,6 +35,7 @@ function normalizeServerLicense(license: RawLicense): PublicLicense {
return {
uid: license.uid,
type: license.type,
mode: license.mode,
expiryDateInMillis: license.expiry_date_in_millis,
status: license.status,
};
@ -89,9 +91,13 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
public async setup(core: CoreSetup) {
this.logger.debug('Setting up Licensing plugin');
const config = await this.config$.pipe(take(1)).toPromise();
const pollingFrequency = config.api_polling_frequency;
const dataClient = await core.elasticsearch.dataClient$.pipe(take(1)).toPromise();
const { refresh, license$ } = this.createLicensePoller(dataClient, config.pollingFrequency);
const { refresh, license$ } = this.createLicensePoller(
dataClient,
pollingFrequency.asMilliseconds()
);
core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$));
@ -101,11 +107,14 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
return {
refresh,
license$,
createLicensePoller: this.createLicensePoller.bind(this),
};
}
private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: Duration) {
const intervalRefresh$ = timer(0, pollingFrequency.asMilliseconds());
private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: number) {
this.logger.debug(`Polling Elasticsearch License API with frequency ${pollingFrequency}ms.`);
const intervalRefresh$ = timer(0, pollingFrequency);
const { license$, refreshManually } = createLicenseUpdate(intervalRefresh$, this.stop$, () =>
this.fetchLicense(clusterClient)

View file

@ -3,6 +3,8 @@
* 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 { IClusterClient } from 'src/core/server';
import { ILicense, LicenseStatus, LicenseType } from '../common/types';
export interface ElasticsearchError extends Error {
@ -34,6 +36,7 @@ export interface RawLicense {
status: LicenseStatus;
expiry_date_in_millis: number;
type: LicenseType;
mode: LicenseType;
}
declare module 'src/core/server' {
@ -43,3 +46,25 @@ declare module 'src/core/server' {
};
}
}
/** @public */
export interface LicensingPluginSetup {
/**
* Steam of licensing information {@link ILicense}.
*/
license$: Observable<ILicense>;
/**
* Triggers licensing information re-fetch.
*/
refresh(): Promise<ILicense>;
/**
* Creates a license poller to retrieve a license data with.
* Allows a plugin to configure a cluster to retrieve data from at
* given polling frequency.
*/
createLicensePoller: (
clusterClient: IClusterClient,
pollingFrequency: number
) => { license$: Observable<ILicense>; refresh(): Promise<ILicense> };
}

View file

@ -73,7 +73,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) {
},
async getLicense(): Promise<PublicLicenseJSON> {
// > --xpack.licensing.pollingFrequency set in test config
// > --xpack.licensing.api_polling_frequency set in test config
// to wait for Kibana server to re-fetch the license from Elasticsearch
await delay(1000);
@ -97,30 +97,71 @@ export default function({ getService, getPageObjects }: FtrProviderContext) {
isEnabled: true,
});
const {
body: legacyInitialLicense,
headers: legacyInitialLicenseHeaders,
} = await supertest.get('/api/xpack/v1/info').expect(200);
expect(legacyInitialLicense.license?.type).to.be('basic');
expect(legacyInitialLicense.features).to.have.property('security');
expect(legacyInitialLicenseHeaders['kbn-xpack-sig']).to.be.a('string');
// license hasn't changed
const refetchedLicense = await scenario.getLicense();
expect(refetchedLicense.license?.type).to.be('basic');
expect(refetchedLicense.signature).to.be(initialLicense.signature);
const {
body: legacyRefetchedLicense,
headers: legacyRefetchedLicenseHeaders,
} = await supertest.get('/api/xpack/v1/info').expect(200);
expect(legacyRefetchedLicense.license?.type).to.be('basic');
expect(legacyRefetchedLicenseHeaders['kbn-xpack-sig']).to.be(
legacyInitialLicenseHeaders['kbn-xpack-sig']
);
// server allows to request trial only once.
// other attempts will throw 403
await scenario.startTrial();
const trialLicense = await scenario.getLicense();
expect(trialLicense.license?.type).to.be('trial');
expect(trialLicense.signature).to.not.be(initialLicense.signature);
expect(trialLicense.features?.security).to.eql({
isAvailable: true,
isEnabled: true,
});
const { body: legacyTrialLicense, headers: legacyTrialLicenseHeaders } = await supertest
.get('/api/xpack/v1/info')
.expect(200);
expect(legacyTrialLicense.license?.type).to.be('trial');
expect(legacyTrialLicense.features).to.have.property('security');
expect(legacyTrialLicenseHeaders['kbn-xpack-sig']).to.not.be(
legacyInitialLicenseHeaders['kbn-xpack-sig']
);
await scenario.startBasic();
const basicLicense = await scenario.getLicense();
expect(basicLicense.license?.type).to.be('basic');
expect(basicLicense.signature).not.to.be(initialLicense.signature);
expect(trialLicense.features?.security).to.eql({
expect(basicLicense.features?.security).to.eql({
isAvailable: true,
isEnabled: true,
});
const { body: legacyBasicLicense, headers: legacyBasicLicenseHeaders } = await supertest
.get('/api/xpack/v1/info')
.expect(200);
expect(legacyBasicLicense.license?.type).to.be('basic');
expect(legacyBasicLicense.features).to.have.property('security');
expect(legacyBasicLicenseHeaders['kbn-xpack-sig']).to.not.be(
legacyInitialLicenseHeaders['kbn-xpack-sig']
);
await scenario.deleteLicense();
const inactiveLicense = await scenario.getLicense();
expect(inactiveLicense.signature).to.not.be(initialLicense.signature);

View file

@ -43,7 +43,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) {
...functionalTestsConfig.get('kbnTestServer'),
serverArgs: [
...functionalTestsConfig.get('kbnTestServer.serverArgs'),
'--xpack.licensing.pollingFrequency=300',
'--xpack.licensing.api_polling_frequency=300',
],
},