Skip to content

Commit

Permalink
[licensing] add license fetcher cache (elastic#170006)
Browse files Browse the repository at this point in the history
## Summary

Related to elastic#169788
Fix elastic#117394

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
pgayvallet and kibanamachine authored Oct 30, 2023
1 parent 540e6c0 commit 21c0b0b
Show file tree
Hide file tree
Showing 8 changed files with 386 additions and 210 deletions.
15 changes: 10 additions & 5 deletions x-pack/plugins/licensing/common/license_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
takeUntil,
finalize,
startWith,
throttleTime,
} from 'rxjs/operators';
import { hasLicenseInfoChanged } from './has_license_info_changed';
import type { ILicense } from './types';
Expand All @@ -29,11 +30,15 @@ export function createLicenseUpdate(
) {
const manuallyRefresh$ = new Subject<void>();

const fetched$ = merge(triggerRefresh$, manuallyRefresh$).pipe(
takeUntil(stop$),
exhaustMap(fetcher),
share()
);
const fetched$ = merge(
triggerRefresh$,
manuallyRefresh$.pipe(
throttleTime(1000, undefined, {
leading: true,
trailing: true,
})
)
).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];
Expand Down
172 changes: 172 additions & 0 deletions x-pack/plugins/licensing/server/license_fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* 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');
});
});
133 changes: 133 additions & 0 deletions x-pack/plugins/licensing/server/license_fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* 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;
}
12 changes: 9 additions & 3 deletions x-pack/plugins/licensing/server/licensing_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ 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: schema.object({
api_polling_frequency: schema.duration({ defaultValue: '30s' }),
}),
schema: configSchema,
};
Loading

0 comments on commit 21c0b0b

Please sign in to comment.