[Synthetics] fetch license info in service api client (#154770)

## Summary

Summarize your PR. If it involves visual changes include a screenshot or
gif.

Resolves https://github.com/elastic/kibana/issues/152723
Resolves https://github.com/elastic/synthetics-dev/issues/196

Fetches the license information and sends it as `license_level` to the
synthetics service.

Also stops syncing with the service if the license is expired.
This commit is contained in:
Dominique Clarke 2023-04-13 13:20:10 -04:00 committed by GitHub
parent 7199327bca
commit 92c8699cc2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 320 additions and 49 deletions

View file

@ -6,9 +6,8 @@
*/
import { loggerMock } from '@kbn/logging-mocks';
jest.mock('axios', () => jest.fn());
import { CoreStart } from '@kbn/core/server';
import { coreMock } from '@kbn/core/server/mocks';
import { Logger } from '@kbn/core/server';
import { ServiceAPIClient } from './service_api_client';
import { UptimeServerSetup } from '../legacy_uptime/lib/adapters';
@ -16,16 +15,37 @@ import { ServiceConfig } from '../../common/config';
import axios from 'axios';
import { LocationStatus, PublicLocations } from '../../common/runtime_types';
jest.mock('axios', () => jest.fn());
jest.mock('@kbn/server-http-tools', () => ({
...jest.requireActual('@kbn/server-http-tools'),
SslConfig: jest.fn().mockImplementation(({ certificate, key }) => ({ certificate, key })),
}));
const mockCoreStart = coreMock.createStart() as CoreStart;
mockCoreStart.elasticsearch.client.asInternalUser.license.get = jest.fn().mockResolvedValue({
license: {
status: 'active',
uid: 'c5788419-1c6f-424a-9217-da7a0a9151a0',
type: 'platinum',
issue_date: '2022-11-29T00:00:00.000Z',
issue_date_in_millis: 1669680000000,
expiry_date: '2024-12-31T23:59:59.999Z',
expiry_date_in_millis: 1735689599999,
max_nodes: 100,
max_resource_units: null,
issued_to: 'Elastic - INTERNAL (development environments)',
issuer: 'API',
start_date_in_millis: 1669680000000,
},
});
describe('getHttpsAgent', () => {
it('does not use certs if basic auth is set', () => {
const apiClient = new ServiceAPIClient(
jest.fn() as unknown as Logger,
{ username: 'u', password: 'p' },
{ isDev: true } as UptimeServerSetup
{ isDev: true, coreStart: mockCoreStart } as UptimeServerSetup
);
const { options: result } = apiClient.getHttpsAgent('https://localhost:10001');
expect(result).not.toHaveProperty('cert');
@ -36,7 +56,7 @@ describe('getHttpsAgent', () => {
const apiClient = new ServiceAPIClient(
jest.fn() as unknown as Logger,
{ tls: { certificate: 'crt', key: 'k' } } as ServiceConfig,
{ isDev: true } as UptimeServerSetup
{ isDev: true, coreStart: mockCoreStart } as UptimeServerSetup
);
const { options: result } = apiClient.getHttpsAgent('https://example.com');
@ -47,7 +67,7 @@ describe('getHttpsAgent', () => {
const apiClient = new ServiceAPIClient(
jest.fn() as unknown as Logger,
{ tls: { certificate: 'crt', key: 'k' } } as ServiceConfig,
{ isDev: false } as UptimeServerSetup
{ isDev: false, coreStart: mockCoreStart } as UptimeServerSetup
);
const { options: result } = apiClient.getHttpsAgent('https://localhost:10001');
@ -58,7 +78,7 @@ describe('getHttpsAgent', () => {
const apiClient = new ServiceAPIClient(
jest.fn() as unknown as Logger,
{ tls: { certificate: 'crt', key: 'k' } } as ServiceConfig,
{ isDev: false } as UptimeServerSetup
{ isDev: false, coreStart: mockCoreStart } as UptimeServerSetup
);
const { options: result } = apiClient.getHttpsAgent('https://localhost:10001');
@ -77,7 +97,7 @@ describe('checkAccountAccessStatus', () => {
const apiClient = new ServiceAPIClient(
jest.fn() as unknown as Logger,
{ tls: { certificate: 'crt', key: 'k' } } as ServiceConfig,
{ isDev: false, stackVersion: '8.4' } as UptimeServerSetup
{ isDev: false, stackVersion: '8.4', coreStart: mockCoreStart } as UptimeServerSetup
);
apiClient.locations = [
@ -110,6 +130,7 @@ describe('checkAccountAccessStatus', () => {
describe('callAPI', () => {
beforeEach(() => {
(axios as jest.MockedFunction<typeof axios>).mockReset();
jest.clearAllMocks();
});
afterEach(() => jest.restoreAllMocks());
@ -134,6 +155,7 @@ describe('callAPI', () => {
const apiClient = new ServiceAPIClient(logger, config, {
isDev: true,
stackVersion: '8.7.0',
coreStart: mockCoreStart,
} as UptimeServerSetup);
const spy = jest.spyOn(apiClient, 'callServiceEndpoint');
@ -145,6 +167,7 @@ describe('callAPI', () => {
await apiClient.callAPI('POST', {
monitors: testMonitors,
output,
licenseLevel: 'trial',
});
expect(spy).toHaveBeenCalledTimes(3);
@ -159,6 +182,7 @@ describe('callAPI', () => {
monitor.locations.some((loc: any) => loc.id === 'us_central')
),
output,
licenseLevel: 'trial',
},
'POST',
devUrl
@ -173,6 +197,7 @@ describe('callAPI', () => {
monitor.locations.some((loc: any) => loc.id === 'us_central_qa')
),
output,
licenseLevel: 'trial',
},
'POST',
'https://qa.service.elstc.co'
@ -187,6 +212,7 @@ describe('callAPI', () => {
monitor.locations.some((loc: any) => loc.id === 'us_central_staging')
),
output,
licenseLevel: 'trial',
},
'POST',
'https://qa.service.stg.co'
@ -194,7 +220,13 @@ describe('callAPI', () => {
expect(axiosSpy).toHaveBeenCalledTimes(3);
expect(axiosSpy).toHaveBeenNthCalledWith(1, {
data: { monitors: request1, is_edit: undefined, output, stack_version: '8.7.0' },
data: {
monitors: request1,
is_edit: undefined,
output,
stack_version: '8.7.0',
license_level: 'trial',
},
headers: {
Authorization: 'Basic ZGV2OjEyMzQ1',
'x-kibana-version': '8.7.0',
@ -207,7 +239,13 @@ describe('callAPI', () => {
});
expect(axiosSpy).toHaveBeenNthCalledWith(2, {
data: { monitors: request2, is_edit: undefined, output, stack_version: '8.7.0' },
data: {
monitors: request2,
is_edit: undefined,
output,
stack_version: '8.7.0',
license_level: 'trial',
},
headers: {
Authorization: 'Basic ZGV2OjEyMzQ1',
'x-kibana-version': '8.7.0',
@ -220,7 +258,13 @@ describe('callAPI', () => {
});
expect(axiosSpy).toHaveBeenNthCalledWith(3, {
data: { monitors: request3, is_edit: undefined, output, stack_version: '8.7.0' },
data: {
monitors: request3,
is_edit: undefined,
output,
stack_version: '8.7.0',
license_level: 'trial',
},
headers: {
Authorization: 'Basic ZGV2OjEyMzQ1',
'x-kibana-version': '8.7.0',
@ -273,6 +317,7 @@ describe('callAPI', () => {
{
isDev: true,
stackVersion: '8.7.0',
coreStart: mockCoreStart,
} as UptimeServerSetup
);
@ -283,10 +328,17 @@ describe('callAPI', () => {
await apiClient.callAPI('POST', {
monitors: testMonitors,
output,
licenseLevel: 'platinum',
});
expect(axiosSpy).toHaveBeenNthCalledWith(1, {
data: { monitors: request1, is_edit: undefined, output, stack_version: '8.7.0' },
data: {
monitors: request1,
is_edit: undefined,
output,
stack_version: '8.7.0',
license_level: 'platinum',
},
headers: {
'x-kibana-version': '8.7.0',
},

View file

@ -27,6 +27,7 @@ export interface ServiceData {
};
runOnce?: boolean;
isEdit?: boolean;
licenseLevel: string;
}
export class ServiceAPIClient {
@ -111,7 +112,7 @@ export class ServiceAPIClient {
const url = this.locations[Math.floor(Math.random() * this.locations.length)].url;
/* url is required for service locations, but omitted for private locations.
/* this.locations is only service locations */
/* this.locations is only service locations */
const httpsAgent = this.getHttpsAgent(url);
if (httpsAgent) {
@ -137,7 +138,7 @@ export class ServiceAPIClient {
async callAPI(
method: 'POST' | 'PUT' | 'DELETE',
{ monitors: allMonitors, output, runOnce, isEdit }: ServiceData
{ monitors: allMonitors, output, runOnce, isEdit, licenseLevel }: ServiceData
) {
if (this.username === TEST_SERVICE_USERNAME) {
// we don't want to call service while local integration tests are running
@ -156,7 +157,7 @@ export class ServiceAPIClient {
promises.push(
rxjsFrom(
this.callServiceEndpoint(
{ monitors: locMonitors, isEdit, runOnce, output },
{ monitors: locMonitors, isEdit, runOnce, output, licenseLevel },
method,
url
)
@ -196,7 +197,7 @@ export class ServiceAPIClient {
}
async callServiceEndpoint(
{ monitors, output, runOnce, isEdit }: ServiceData,
{ monitors, output, runOnce, isEdit, licenseLevel }: ServiceData,
method: 'POST' | 'PUT' | 'DELETE',
url: string
) {
@ -214,6 +215,7 @@ export class ServiceAPIClient {
output,
stack_version: this.stackVersion,
is_edit: isEdit,
license_level: licenseLevel,
},
headers: this.authorization
? {

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { loggerMock } from '@kbn/logging-mocks';
import { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server';
import { KibanaRequest, SavedObjectsClientContract, CoreStart } from '@kbn/core/server';
import { coreMock } from '@kbn/core/server/mocks';
import { SyntheticsMonitorClient } from './synthetics_monitor_client';
import { UptimeServerSetup } from '../../legacy_uptime/lib/adapters';
import { SyntheticsService } from '../synthetics_service';
@ -18,6 +19,25 @@ import {
} from '../../../common/runtime_types';
import { mockEncryptedSO } from '../utils/mocks';
const mockCoreStart = coreMock.createStart() as CoreStart;
mockCoreStart.elasticsearch.client.asInternalUser.license.get = jest.fn().mockResolvedValue({
license: {
status: 'active',
uid: 'c5788419-1c6f-424a-9217-da7a0a9151a0',
type: 'platinum',
issue_date: '2022-11-29T00:00:00.000Z',
issue_date_in_millis: 1669680000000,
expiry_date: '2024-12-31T23:59:59.999Z',
expiry_date_in_millis: 1735689599999,
max_nodes: 100,
max_resource_units: null,
issued_to: 'Elastic - INTERNAL (development environments)',
issuer: 'API',
start_date_in_millis: 1669680000000,
},
});
describe('SyntheticsMonitorClient', () => {
const mockEsClient = {
search: jest.fn(),

View file

@ -5,9 +5,9 @@
* 2.0.
*/
jest.mock('axios', () => jest.fn());
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { coreMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
import { CoreStart } from '@kbn/core/server';
import { SyntheticsService } from './synthetics_service';
import { loggerMock } from '@kbn/logging-mocks';
import { UptimeServerSetup } from '../legacy_uptime/lib/adapters';
@ -16,8 +16,50 @@ import times from 'lodash/times';
import { LocationStatus, HeartbeatConfig } from '../../common/runtime_types';
import { mockEncryptedSO } from './utils/mocks';
jest.mock('axios', () => jest.fn());
const taskManagerSetup = taskManagerMock.createSetup();
const mockCoreStart = coreMock.createStart() as CoreStart;
mockCoreStart.elasticsearch.client.asInternalUser.license.get = jest.fn().mockResolvedValue({
license: {
status: 'active',
uid: 'c5788419-1c6f-424a-9217-da7a0a9151a0',
type: 'platinum',
issue_date: '2022-11-29T00:00:00.000Z',
issue_date_in_millis: 1669680000000,
expiry_date: '2024-12-31T23:59:59.999Z',
expiry_date_in_millis: 1735689599999,
max_nodes: 100,
max_resource_units: null,
issued_to: 'Elastic - INTERNAL (development environments)',
issuer: 'API',
start_date_in_millis: 1669680000000,
},
});
const getFakePayload = (locations: HeartbeatConfig['locations']) => {
return {
type: 'http',
enabled: true,
schedule: {
number: '3',
unit: 'm',
},
name: 'my mon',
locations,
urls: 'http://google.com',
max_redirects: '0',
password: '',
proxy_url: '',
id: '7af7e2f0-d5dc-11ec-87ac-bdfdb894c53d',
fields: { config_id: '7af7e2f0-d5dc-11ec-87ac-bdfdb894c53d' },
fields_under_root: true,
secrets: '{}',
};
};
describe('SyntheticsService', () => {
const mockEsClient = {
search: jest.fn(),
@ -38,13 +80,12 @@ describe('SyntheticsService', () => {
manifestUrl: 'http://localhost:8080/api/manifest',
},
},
coreStart: mockCoreStart,
encryptedSavedObjects: mockEncryptedSO(),
savedObjectsClient: savedObjectsClientMock.create()!,
} as unknown as UptimeServerSetup;
const getMockedService = (locationsNum: number = 1) => {
serverMock.config = { service: { devUrl: 'http://localhost' } };
const service = new SyntheticsService(serverMock);
const locations = times(locationsNum).map((n) => {
return {
id: `loc-${n}`,
@ -58,6 +99,30 @@ describe('SyntheticsService', () => {
status: LocationStatus.GA,
};
});
serverMock.config = { service: { devUrl: 'http://localhost' } };
if (serverMock.savedObjectsClient) {
serverMock.savedObjectsClient.find = jest.fn().mockResolvedValue({
saved_objects: [
getFakePayload([
{
id: `loc-1`,
label: `Location 1`,
url: `https://example.com/1`,
geo: {
lat: 0,
lon: 0,
},
isServiceManaged: true,
status: LocationStatus.GA,
},
]),
],
total: 1,
per_page: 20,
page: 1,
});
}
const service = new SyntheticsService(serverMock);
service.apiClient.locations = locations;
@ -66,28 +131,9 @@ describe('SyntheticsService', () => {
return { service, locations };
};
const getFakePayload = (locations: HeartbeatConfig['locations']) => {
return {
type: 'http',
enabled: true,
schedule: {
number: '3',
unit: 'm',
},
name: 'my mon',
locations,
urls: 'http://google.com',
max_redirects: '0',
password: '',
proxy_url: '',
id: '7af7e2f0-d5dc-11ec-87ac-bdfdb894c53d',
fields: { config_id: '7af7e2f0-d5dc-11ec-87ac-bdfdb894c53d' },
fields_under_root: true,
};
};
beforeEach(() => {
(axios as jest.MockedFunction<typeof axios>).mockReset();
jest.clearAllMocks();
});
afterEach(() => jest.restoreAllMocks());
@ -174,6 +220,101 @@ describe('SyntheticsService', () => {
})
);
});
it('includes the license level flag on edit requests', async () => {
const { service, locations } = getMockedService();
(axios as jest.MockedFunction<typeof axios>).mockResolvedValue({} as AxiosResponse);
const payload = getFakePayload([locations[0]]);
await service.editConfig({ monitor: payload } as any);
expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ license_level: 'platinum' }),
})
);
});
it('includes the license level flag on add config requests', async () => {
const { service, locations } = getMockedService();
(axios as jest.MockedFunction<typeof axios>).mockResolvedValue({} as AxiosResponse);
const payload = getFakePayload([locations[0]]);
await service.addConfig({ monitor: payload } as any);
expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ license_level: 'platinum' }),
})
);
});
it('includes the license level flag on push configs requests', async () => {
const { service, locations } = getMockedService();
serverMock.encryptedSavedObjects = mockEncryptedSO({
attributes: getFakePayload([locations[0]]),
}) as any;
(axios as jest.MockedFunction<typeof axios>).mockResolvedValue({} as AxiosResponse);
await service.pushConfigs();
expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ license_level: 'platinum' }),
})
);
});
it.each([
[true, 'Cannot sync monitors with the Synthetics service. License is expired.'],
[
false,
'Cannot sync monitors with the Synthetics service. Unable to determine license level.',
],
])(
'does not call api when license is expired or unavailable',
async (isExpired, errorMessage) => {
const { service, locations } = getMockedService();
mockCoreStart.elasticsearch.client.asInternalUser.license.get = jest
.fn()
.mockResolvedValue({
license: isExpired
? {
status: 'expired',
uid: 'c5788419-1c6f-424a-9217-da7a0a9151a0',
type: 'platinum',
issue_date: '2022-11-29T00:00:00.000Z',
issue_date_in_millis: 1669680000000,
expiry_date: '2022-12-31T23:59:59.999Z',
expiry_date_in_millis: 1735689599999,
max_nodes: 100,
max_resource_units: null,
issued_to: 'Elastic - INTERNAL (development environments)',
issuer: 'API',
start_date_in_millis: 1669680000000,
}
: undefined,
});
serverMock.encryptedSavedObjects = mockEncryptedSO({
attributes: getFakePayload([locations[0]]),
}) as any;
(axios as jest.MockedFunction<typeof axios>).mockResolvedValue({} as AxiosResponse);
await expect(service.pushConfigs()).rejects.toThrow(errorMessage);
}
);
});
describe('getSyntheticsParams', () => {
@ -182,6 +323,11 @@ describe('SyntheticsService', () => {
(axios as jest.MockedFunction<typeof axios>).mockResolvedValue({} as AxiosResponse);
serverMock.encryptedSavedObjects = mockEncryptedSO({
attributes: { key: 'username', value: 'elastic' },
namespaces: ['*'],
}) as any;
const params = await service.getSyntheticsParams();
expect(params).toEqual({
@ -190,6 +336,7 @@ describe('SyntheticsService', () => {
},
});
});
it('returns the params for specific space', async () => {
const { service } = getMockedService();
@ -207,9 +354,10 @@ describe('SyntheticsService', () => {
it('returns the space limited params', async () => {
const { service } = getMockedService();
serverMock.encryptedSavedObjects = mockEncryptedSO([
{ attributes: { key: 'username', value: 'elastic' }, namespaces: ['default'] },
]) as any;
serverMock.encryptedSavedObjects = mockEncryptedSO({
attributes: { key: 'username', value: 'elastic' },
namespaces: ['default'],
}) as any;
const params = await service.getSyntheticsParams({ spaceId: 'default' });

View file

@ -7,7 +7,7 @@
/* eslint-disable max-classes-per-file */
import { Logger, SavedObject } from '@kbn/core/server';
import { Logger, SavedObject, ElasticsearchClient } from '@kbn/core/server';
import {
ConcreteTaskInstance,
TaskInstance,
@ -56,6 +56,7 @@ const SYNTHETICS_SERVICE_SYNC_INTERVAL_DEFAULT = '5m';
export class SyntheticsService {
private logger: Logger;
private esClient?: ElasticsearchClient;
private readonly server: UptimeServerSetup;
public apiClient: ServiceAPIClient;
@ -242,6 +243,42 @@ export class SyntheticsService {
}
}
private async getLicense() {
this.esClient = this.getESClient();
let license;
if (this.esClient === undefined || this.esClient === null) {
throw Error(
'Cannot sync monitors with the Synthetics service. Elasticsearch client is unavailable: cannot retrieve license information'
);
}
try {
license = (await this.esClient.license.get())?.license;
} catch (e) {
throw new Error(
`Cannot sync monitors with the Synthetics service. Unable to determine license level: ${e}`
);
}
if (license?.status === 'expired') {
throw new Error('Cannot sync monitors with the Synthetics service. License is expired.');
}
if (!license?.type) {
throw new Error(
'Cannot sync monitors with the Synthetics service. Unable to determine license level.'
);
}
return license;
}
private getESClient() {
if (!this.server.coreStart) {
return;
}
return this.server.coreStart?.elasticsearch.client.asInternalUser;
}
async getOutput() {
const { apiKey, isValid } = await getAPIKeyForSyntheticsService({ server: this.server });
if (!isValid) {
@ -261,6 +298,7 @@ export class SyntheticsService {
async addConfig(config: ConfigData | ConfigData[]) {
try {
const monitors = this.formatConfigs(Array.isArray(config) ? config : [config]);
const license = await this.getLicense();
const output = await this.getOutput();
if (output) {
@ -269,6 +307,7 @@ export class SyntheticsService {
this.syncErrors = await this.apiClient.post({
monitors,
output,
licenseLevel: license.type,
});
}
return this.syncErrors;
@ -279,6 +318,7 @@ export class SyntheticsService {
async editConfig(monitorConfig: ConfigData | ConfigData[], isEdit = true) {
try {
const license = await this.getLicense();
const monitors = this.formatConfigs(
Array.isArray(monitorConfig) ? monitorConfig : [monitorConfig]
);
@ -289,6 +329,7 @@ export class SyntheticsService {
monitors,
output,
isEdit,
licenseLevel: license.type,
};
this.syncErrors = await this.apiClient.put(data);
@ -300,6 +341,7 @@ export class SyntheticsService {
}
async pushConfigs() {
const license = await this.getLicense();
const service = this;
const subject = new Subject<MonitorFields[]>();
@ -326,6 +368,7 @@ export class SyntheticsService {
service.syncErrors = await this.apiClient.put({
monitors,
output,
licenseLevel: license.type,
});
} catch (e) {
sendErrorTelemetryEvents(service.logger, service.server.telemetry, {
@ -344,6 +387,7 @@ export class SyntheticsService {
}
async runOnceConfigs(configs: ConfigData) {
const license = await this.getLicense();
const monitors = this.formatConfigs(configs);
if (monitors.length === 0) {
return;
@ -358,6 +402,7 @@ export class SyntheticsService {
return await this.apiClient.runOnce({
monitors,
output,
licenseLevel: license.type,
});
} catch (e) {
this.logger.error(e);
@ -366,6 +411,7 @@ export class SyntheticsService {
}
async deleteConfigs(configs: ConfigData[]) {
const license = await this.getLicense();
const hasPublicLocations = configs.some((config) =>
config.monitor.locations.some(({ isServiceManaged }) => isServiceManaged)
);
@ -379,12 +425,14 @@ export class SyntheticsService {
const data = {
output,
monitors: this.formatConfigs(configs),
licenseLevel: license.type,
};
return await this.apiClient.delete(data);
}
}
async deleteAllConfigs() {
const license = await this.getLicense();
const subject = new Subject<MonitorFields[]>();
subject.subscribe(async (monitors) => {
@ -401,6 +449,7 @@ export class SyntheticsService {
const data = {
output,
monitors,
licenseLevel: license.type,
};
return await this.apiClient.delete(data);
}

View file

@ -8,16 +8,16 @@
import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server';
export const mockEncryptedSO = (
data: any = [{ attributes: { key: 'username', value: 'elastic' }, namespaces: ['*'] }]
data: any = { attributes: { key: 'username', value: 'elastic' }, namespaces: ['*'] }
) => ({
getClient: jest.fn().mockReturnValue({
getDecryptedAsInternalUser: jest.fn(),
getDecryptedAsInternalUser: jest.fn().mockResolvedValue(data),
createPointInTimeFinderDecryptedAsInternalUser: jest.fn().mockImplementation(() => ({
close: jest.fn(),
find: jest.fn().mockReturnValue({
async *[Symbol.asyncIterator]() {
yield {
saved_objects: data,
saved_objects: [data],
};
},
}),