Revert "[licensing] add license fetcher cache (#170006)" (#170185)

This reverts commit 21c0b0b420.
This commit is contained in:
Jon 2023-10-30 15:52:24 -05:00 committed by GitHub
parent 788d302396
commit d7ab75ffc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 211 additions and 387 deletions

View file

@ -17,7 +17,6 @@ import {
takeUntil,
finalize,
startWith,
throttleTime,
} from 'rxjs/operators';
import { hasLicenseInfoChanged } from './has_license_info_changed';
import type { ILicense } from './types';
@ -30,15 +29,11 @@ export function createLicenseUpdate(
) {
const manuallyRefresh$ = new Subject<void>();
const fetched$ = merge(
triggerRefresh$,
manuallyRefresh$.pipe(
throttleTime(1000, undefined, {
leading: true,
trailing: true,
})
)
).pipe(takeUntil(stop$), exhaustMap(fetcher), share());
const fetched$ = merge(triggerRefresh$, manuallyRefresh$).pipe(
takeUntil(stop$),
exhaustMap(fetcher),
share()
);
// provide a first, empty license, so that we can compare in the filter below
const startWithArgs = initialValues ? [undefined, initialValues] : [undefined];

View file

@ -1,172 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { getLicenseFetcher } from './license_fetcher';
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
type EsLicense = estypes.XpackInfoMinimalLicenseInformation;
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
function buildRawLicense(options: Partial<EsLicense> = {}): EsLicense {
return {
uid: 'uid-000000001234',
status: 'active',
type: 'basic',
mode: 'basic',
expiry_date_in_millis: 1000,
...options,
};
}
describe('LicenseFetcher', () => {
let logger: MockedLogger;
let clusterClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>;
beforeEach(() => {
logger = loggerMock.create();
clusterClient = elasticsearchServiceMock.createClusterClient();
});
it('returns the license for successful calls', async () => {
clusterClient.asInternalUser.xpack.info.mockResponse({
license: buildRawLicense({
uid: 'license-1',
}),
features: {},
} as any);
const fetcher = getLicenseFetcher({
logger,
clusterClient,
cacheDurationMs: 50_000,
});
const license = await fetcher();
expect(license.uid).toEqual('license-1');
});
it('returns the latest license for successful calls', async () => {
clusterClient.asInternalUser.xpack.info
.mockResponseOnce({
license: buildRawLicense({
uid: 'license-1',
}),
features: {},
} as any)
.mockResponseOnce({
license: buildRawLicense({
uid: 'license-2',
}),
features: {},
} as any);
const fetcher = getLicenseFetcher({
logger,
clusterClient,
cacheDurationMs: 50_000,
});
let license = await fetcher();
expect(license.uid).toEqual('license-1');
license = await fetcher();
expect(license.uid).toEqual('license-2');
});
it('returns an error license in case of error', async () => {
clusterClient.asInternalUser.xpack.info.mockResponseImplementation(() => {
throw new Error('woups');
});
const fetcher = getLicenseFetcher({
logger,
clusterClient,
cacheDurationMs: 50_000,
});
const license = await fetcher();
expect(license.error).toEqual('woups');
});
it('returns a license successfully fetched after an error', async () => {
clusterClient.asInternalUser.xpack.info
.mockResponseImplementationOnce(() => {
throw new Error('woups');
})
.mockResponseOnce({
license: buildRawLicense({
uid: 'license-1',
}),
features: {},
} as any);
const fetcher = getLicenseFetcher({
logger,
clusterClient,
cacheDurationMs: 50_000,
});
let license = await fetcher();
expect(license.error).toEqual('woups');
license = await fetcher();
expect(license.uid).toEqual('license-1');
});
it('returns the latest fetched license after an error within the cache duration period', async () => {
clusterClient.asInternalUser.xpack.info
.mockResponseOnce({
license: buildRawLicense({
uid: 'license-1',
}),
features: {},
} as any)
.mockResponseImplementationOnce(() => {
throw new Error('woups');
});
const fetcher = getLicenseFetcher({
logger,
clusterClient,
cacheDurationMs: 50_000,
});
let license = await fetcher();
expect(license.uid).toEqual('license-1');
license = await fetcher();
expect(license.uid).toEqual('license-1');
});
it('returns an error license after an error exceeding the cache duration period', async () => {
clusterClient.asInternalUser.xpack.info
.mockResponseOnce({
license: buildRawLicense({
uid: 'license-1',
}),
features: {},
} as any)
.mockResponseImplementationOnce(() => {
throw new Error('woups');
});
const fetcher = getLicenseFetcher({
logger,
clusterClient,
cacheDurationMs: 1,
});
let license = await fetcher();
expect(license.uid).toEqual('license-1');
await delay(50);
license = await fetcher();
expect(license.error).toEqual('woups');
});
});

View file

@ -1,133 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { createHash } from 'crypto';
import stringify from 'json-stable-stringify';
import type { MaybePromise } from '@kbn/utility-types';
import { isPromise } from '@kbn/std';
import type { IClusterClient, Logger } from '@kbn/core/server';
import type {
ILicense,
PublicLicense,
PublicFeatures,
LicenseType,
LicenseStatus,
} from '../common/types';
import { License } from '../common/license';
import type { ElasticsearchError, LicenseFetcher } from './types';
export const getLicenseFetcher = ({
clusterClient,
logger,
cacheDurationMs,
}: {
clusterClient: MaybePromise<IClusterClient>;
logger: Logger;
cacheDurationMs: number;
}): LicenseFetcher => {
let currentLicense: ILicense | undefined;
let lastSuccessfulFetchTime: number | undefined;
return async () => {
const client = isPromise(clusterClient) ? await clusterClient : clusterClient;
try {
const response = await client.asInternalUser.xpack.info();
const normalizedLicense =
response.license && response.license.type !== 'missing'
? normalizeServerLicense(response.license)
: undefined;
const normalizedFeatures = response.features
? normalizeFeatures(response.features)
: undefined;
const signature = sign({
license: normalizedLicense,
features: normalizedFeatures,
error: '',
});
currentLicense = new License({
license: normalizedLicense,
features: normalizedFeatures,
signature,
});
lastSuccessfulFetchTime = Date.now();
return currentLicense;
} catch (error) {
logger.warn(
`License information could not be obtained from Elasticsearch due to ${error} error`
);
if (lastSuccessfulFetchTime && lastSuccessfulFetchTime + cacheDurationMs > Date.now()) {
return currentLicense!;
} else {
const errorMessage = getErrorMessage(error);
const signature = sign({ error: errorMessage });
return new License({
error: getErrorMessage(error),
signature,
});
}
}
};
};
function normalizeServerLicense(
license: estypes.XpackInfoMinimalLicenseInformation
): PublicLicense {
return {
uid: license.uid,
type: license.type as LicenseType,
mode: license.mode as LicenseType,
expiryDateInMillis:
typeof license.expiry_date_in_millis === 'string'
? parseInt(license.expiry_date_in_millis, 10)
: license.expiry_date_in_millis,
status: license.status as LicenseStatus,
};
}
function normalizeFeatures(rawFeatures: estypes.XpackInfoFeatures) {
const features: PublicFeatures = {};
for (const [name, feature] of Object.entries(rawFeatures)) {
features[name] = {
isAvailable: feature.available,
isEnabled: feature.enabled,
};
}
return features;
}
function sign({
license,
features,
error,
}: {
license?: PublicLicense;
features?: PublicFeatures;
error?: string;
}) {
return createHash('sha256')
.update(
stringify({
license,
features,
error,
})
)
.digest('hex');
}
function getErrorMessage(error: ElasticsearchError): string {
if (error.status === 400) {
return 'X-Pack plugin is not installed on the Elasticsearch cluster.';
}
return error.message;
}

View file

@ -10,18 +10,12 @@ import { PluginConfigDescriptor } from '@kbn/core/server';
const configSchema = schema.object({
api_polling_frequency: schema.duration({ defaultValue: '30s' }),
license_cache_duration: schema.duration({
defaultValue: '300s',
validate: (value) => {
if (value.asMinutes() > 15) {
return 'license cache duration must be shorter than 15 minutes';
}
},
}),
});
export type LicenseConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<LicenseConfigType> = {
schema: configSchema,
schema: schema.object({
api_polling_frequency: schema.duration({ defaultValue: '30s' }),
}),
};

View file

@ -56,23 +56,22 @@ describe('licensing plugin', () => {
return client;
};
let plugin: LicensingPlugin;
let pluginInitContextMock: ReturnType<typeof coreMock.createPluginInitializerContext>;
beforeEach(() => {
pluginInitContextMock = coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(100),
license_cache_duration: moment.duration(1000),
});
plugin = new LicensingPlugin(pluginInitContextMock);
});
afterEach(async () => {
await plugin?.stop();
});
describe('#start', () => {
describe('#license$', () => {
let plugin: LicensingPlugin;
let pluginInitContextMock: ReturnType<typeof coreMock.createPluginInitializerContext>;
beforeEach(() => {
pluginInitContextMock = coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(100),
});
plugin = new LicensingPlugin(pluginInitContextMock);
});
afterEach(async () => {
await plugin.stop();
});
it('returns license', async () => {
const esClient = createEsClient({
license: buildRawLicense(),
@ -80,8 +79,8 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
const license = await firstValueFrom(license$);
expect(license.isAvailable).toBe(true);
});
@ -93,8 +92,8 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
await firstValueFrom(license$);
expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(1);
@ -112,8 +111,8 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray()));
expect(first.type).toBe('basic');
@ -126,8 +125,8 @@ describe('licensing plugin', () => {
esClient.asInternalUser.xpack.info.mockRejectedValue(new Error('test'));
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
const license = await firstValueFrom(license$);
expect(license.isAvailable).toBe(false);
@ -141,8 +140,8 @@ describe('licensing plugin', () => {
esClient.asInternalUser.xpack.info.mockRejectedValue(error);
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
const license = await firstValueFrom(license$);
expect(license.isAvailable).toBe(false);
@ -170,8 +169,8 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray()));
@ -187,8 +186,8 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
plugin.start();
await plugin.setup(coreSetup);
await plugin.start();
await flushPromises();
@ -202,8 +201,8 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
plugin.start();
await plugin.setup(coreSetup);
await plugin.start();
await flushPromises();
@ -230,8 +229,8 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
const [first, second, third] = await firstValueFrom(license$.pipe(take(3), toArray()));
expect(first.signature === third.signature).toBe(true);
@ -240,12 +239,16 @@ describe('licensing plugin', () => {
});
describe('#refresh', () => {
let plugin: LicensingPlugin;
afterEach(async () => {
await plugin.stop();
});
it('forces refresh immediately', async () => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
// disable polling mechanism
api_polling_frequency: moment.duration(50000),
license_cache_duration: moment.duration(1000),
})
);
const esClient = createEsClient({
@ -254,26 +257,31 @@ describe('licensing plugin', () => {
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { refresh, license$ } = plugin.start();
await plugin.setup(coreSetup);
const { refresh, license$ } = await plugin.start();
expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(0);
await firstValueFrom(license$);
await license$.pipe(take(1)).toPromise();
expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(1);
await refresh();
refresh();
await flushPromises();
expect(esClient.asInternalUser.xpack.info).toHaveBeenCalledTimes(2);
});
});
describe('#createLicensePoller', () => {
let plugin: LicensingPlugin;
afterEach(async () => {
await plugin.stop();
});
it(`creates a poller fetching license from passed 'clusterClient' every 'api_polling_frequency' ms`, async () => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(50000),
license_cache_duration: moment.duration(1000),
})
);
@ -282,8 +290,8 @@ describe('licensing plugin', () => {
features: {},
});
const coreSetup = createCoreSetupWith(esClient);
plugin.setup(coreSetup);
const { createLicensePoller, license$ } = plugin.start();
await plugin.setup(coreSetup);
const { createLicensePoller, license$ } = await plugin.start();
const customClient = createEsClient({
license: buildRawLicense({ type: 'gold' }),
@ -305,13 +313,19 @@ describe('licensing plugin', () => {
expect(customLicense.isAvailable).toBe(true);
expect(customLicense.type).toBe('gold');
expect(await firstValueFrom(license$)).not.toBe(customLicense);
expect(await license$.pipe(take(1)).toPromise()).not.toBe(customLicense);
});
it('creates a poller with a manual refresh control', async () => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(100),
})
);
const coreSetup = coreMock.createSetup();
plugin.setup(coreSetup);
const { createLicensePoller } = plugin.start();
await plugin.setup(coreSetup);
const { createLicensePoller } = await plugin.start();
const customClient = createEsClient({
license: buildRawLicense({ type: 'gold' }),
@ -330,10 +344,24 @@ describe('licensing plugin', () => {
});
describe('extends core contexts', () => {
let plugin: LicensingPlugin;
beforeEach(() => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(100),
})
);
});
afterEach(async () => {
await plugin.stop();
});
it('provides a licensing context to http routes', async () => {
const coreSetup = coreMock.createSetup();
plugin.setup(coreSetup);
await plugin.setup(coreSetup);
expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(`
Array [
@ -347,10 +375,22 @@ describe('licensing plugin', () => {
});
describe('registers on pre-response interceptor', () => {
let plugin: LicensingPlugin;
beforeEach(() => {
plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({ api_polling_frequency: moment.duration(100) })
);
});
afterEach(async () => {
await plugin.stop();
});
it('once', async () => {
const coreSetup = coreMock.createSetup();
plugin.setup(coreSetup);
await plugin.setup(coreSetup);
expect(coreSetup.http.registerOnPreResponse).toHaveBeenCalledTimes(1);
});
@ -359,9 +399,14 @@ describe('licensing plugin', () => {
describe('#stop', () => {
it('stops polling', async () => {
const plugin = new LicensingPlugin(
coreMock.createPluginInitializerContext({
api_polling_frequency: moment.duration(100),
})
);
const coreSetup = coreMock.createSetup();
plugin.setup(coreSetup);
const { license$ } = plugin.start();
await plugin.setup(coreSetup);
const { license$ } = await plugin.start();
let completed = false;
license$.subscribe({ complete: () => (completed = true) });

View file

@ -8,7 +8,12 @@
import type { Observable, Subject, Subscription } from 'rxjs';
import { ReplaySubject, timer } from 'rxjs';
import moment from 'moment';
import { createHash } from 'crypto';
import stringify from 'json-stable-stringify';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { MaybePromise } from '@kbn/utility-types';
import { isPromise } from '@kbn/std';
import type {
CoreSetup,
Logger,
@ -16,17 +21,73 @@ import type {
PluginInitializerContext,
IClusterClient,
} from '@kbn/core/server';
import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider';
import type { ILicense } from '../common/types';
import type {
ILicense,
PublicLicense,
PublicFeatures,
LicenseType,
LicenseStatus,
} from '../common/types';
import type { LicensingPluginSetup, LicensingPluginStart } from './types';
import { License } from '../common/license';
import { createLicenseUpdate } from '../common/license_update';
import type { ElasticsearchError } from './types';
import { registerRoutes } from './routes';
import { FeatureUsageService } from './services';
import type { LicenseConfigType } from './licensing_config';
import { createRouteHandlerContext } from './licensing_route_handler_context';
import { createOnPreResponseHandler } from './on_pre_response_handler';
import { getPluginStatus$ } from './plugin_status';
import { getLicenseFetcher } from './license_fetcher';
function normalizeServerLicense(
license: estypes.XpackInfoMinimalLicenseInformation
): PublicLicense {
return {
uid: license.uid,
type: license.type as LicenseType,
mode: license.mode as LicenseType,
expiryDateInMillis:
typeof license.expiry_date_in_millis === 'string'
? parseInt(license.expiry_date_in_millis, 10)
: license.expiry_date_in_millis,
status: license.status as LicenseStatus,
};
}
function normalizeFeatures(rawFeatures: estypes.XpackInfoFeatures) {
const features: PublicFeatures = {};
for (const [name, feature] of Object.entries(rawFeatures)) {
features[name] = {
isAvailable: feature.available,
isEnabled: feature.enabled,
};
}
return features;
}
function sign({
license,
features,
error,
}: {
license?: PublicLicense;
features?: PublicFeatures;
error?: string;
}) {
return createHash('sha256')
.update(
stringify({
license,
features,
error,
})
)
.digest('hex');
}
/**
* @public
@ -92,16 +153,9 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
this.logger.debug(`Polling Elasticsearch License API with frequency ${pollingFrequency}ms.`);
const intervalRefresh$ = timer(0, pollingFrequency);
const licenseFetcher = getLicenseFetcher({
clusterClient,
logger: this.logger,
cacheDurationMs: this.config.license_cache_duration.asMilliseconds(),
});
const { license$, refreshManually } = createLicenseUpdate(
intervalRefresh$,
this.stop$,
licenseFetcher
const { license$, refreshManually } = createLicenseUpdate(intervalRefresh$, this.stop$, () =>
this.fetchLicense(clusterClient)
);
this.loggingSubscription = license$.subscribe((license) =>
@ -124,6 +178,50 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
};
}
private fetchLicense = async (clusterClient: MaybePromise<IClusterClient>): Promise<ILicense> => {
const client = isPromise(clusterClient) ? await clusterClient : clusterClient;
try {
const response = await client.asInternalUser.xpack.info();
const normalizedLicense =
response.license && response.license.type !== 'missing'
? normalizeServerLicense(response.license)
: undefined;
const normalizedFeatures = response.features
? normalizeFeatures(response.features)
: undefined;
const signature = sign({
license: normalizedLicense,
features: normalizedFeatures,
error: '',
});
return new License({
license: normalizedLicense,
features: normalizedFeatures,
signature,
});
} catch (error) {
this.logger.warn(
`License information could not be obtained from Elasticsearch due to ${error} error`
);
const errorMessage = this.getErrorMessage(error);
const signature = sign({ error: errorMessage });
return new License({
error: this.getErrorMessage(error),
signature,
});
}
};
private getErrorMessage(error: ElasticsearchError): string {
if (error.status === 400) {
return 'X-Pack plugin is not installed on the Elasticsearch cluster.';
}
return error.message;
}
public start() {
if (!this.refresh || !this.license$) {
throw new Error('Setup has not been completed');

View file

@ -14,8 +14,6 @@ export interface ElasticsearchError extends Error {
status?: number;
}
export type LicenseFetcher = () => Promise<ILicense>;
/**
* Result from remote request fetching raw feature set.
* @internal

View file

@ -15,8 +15,7 @@
"@kbn/i18n",
"@kbn/analytics-client",
"@kbn/subscription-tracking",
"@kbn/core-analytics-browser",
"@kbn/logging-mocks"
"@kbn/core-analytics-browser"
],
"exclude": ["target/**/*"]
}