mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[telemetry] Adds cloud provider metadata. (#95131)
This commit is contained in:
parent
5879d1fdf7
commit
c9cd4a0a99
33 changed files with 1348 additions and 1003 deletions
|
@ -12,8 +12,10 @@ exports[`kibana_usage_collection Runs the setup method without issues 5`] = `fal
|
|||
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 6`] = `false`;
|
||||
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 7`] = `true`;
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 7`] = `false`;
|
||||
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 8`] = `false`;
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 8`] = `true`;
|
||||
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 9`] = `true`;
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 9`] = `false`;
|
||||
|
||||
exports[`kibana_usage_collection Runs the setup method without issues 10`] = `true`;
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { cloudDetectorMock } from './detector/cloud_detector.mock';
|
||||
|
||||
const mock = cloudDetectorMock.create();
|
||||
|
||||
export const cloudDetailsMock = mock.getCloudDetails;
|
||||
export const detectCloudServiceMock = mock.detectCloudService;
|
||||
|
||||
jest.doMock('./detector', () => ({
|
||||
CloudDetector: jest.fn().mockImplementation(() => mock),
|
||||
}));
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { cloudDetailsMock, detectCloudServiceMock } from './cloud_provider_collector.test.mocks';
|
||||
import { loggingSystemMock } from '../../../../../core/server/mocks';
|
||||
import {
|
||||
Collector,
|
||||
createUsageCollectionSetupMock,
|
||||
createCollectorFetchContextMock,
|
||||
} from '../../../../usage_collection/server/usage_collection.mock';
|
||||
|
||||
import { registerCloudProviderUsageCollector } from './cloud_provider_collector';
|
||||
|
||||
describe('registerCloudProviderUsageCollector', () => {
|
||||
let collector: Collector<unknown>;
|
||||
const logger = loggingSystemMock.createLogger();
|
||||
|
||||
const usageCollectionMock = createUsageCollectionSetupMock();
|
||||
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
|
||||
collector = new Collector(logger, config);
|
||||
return createUsageCollectionSetupMock().makeUsageCollector(config);
|
||||
});
|
||||
|
||||
const mockedFetchContext = createCollectorFetchContextMock();
|
||||
|
||||
beforeEach(() => {
|
||||
cloudDetailsMock.mockClear();
|
||||
detectCloudServiceMock.mockClear();
|
||||
registerCloudProviderUsageCollector(usageCollectionMock);
|
||||
});
|
||||
|
||||
test('registered collector is set', () => {
|
||||
expect(collector).not.toBeUndefined();
|
||||
});
|
||||
|
||||
test('isReady() => false when cloud details are not available', () => {
|
||||
cloudDetailsMock.mockReturnValueOnce(undefined);
|
||||
expect(collector.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
test('isReady() => true when cloud details are available', () => {
|
||||
cloudDetailsMock.mockReturnValueOnce({ foo: true });
|
||||
expect(collector.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
test('initiates CloudDetector.detectCloudDetails when called', () => {
|
||||
expect(detectCloudServiceMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('fetch()', () => {
|
||||
test('returns undefined when no details are available', async () => {
|
||||
cloudDetailsMock.mockReturnValueOnce(undefined);
|
||||
await expect(collector.fetch(mockedFetchContext)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns cloud details when defined', async () => {
|
||||
const mockDetails = {
|
||||
name: 'aws',
|
||||
vm_type: 't2.micro',
|
||||
region: 'us-west-2',
|
||||
zone: 'us-west-2a',
|
||||
};
|
||||
|
||||
cloudDetailsMock.mockReturnValueOnce(mockDetails);
|
||||
await expect(collector.fetch(mockedFetchContext)).resolves.toEqual(mockDetails);
|
||||
});
|
||||
|
||||
test('should not fail if invoked when not ready', async () => {
|
||||
cloudDetailsMock.mockReturnValueOnce(undefined);
|
||||
await expect(collector.fetch(mockedFetchContext)).resolves.toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
||||
import { CloudDetector } from './detector';
|
||||
|
||||
interface Usage {
|
||||
name: string;
|
||||
vm_type?: string;
|
||||
region?: string;
|
||||
zone?: string;
|
||||
}
|
||||
|
||||
export function registerCloudProviderUsageCollector(usageCollection: UsageCollectionSetup) {
|
||||
const cloudDetector = new CloudDetector();
|
||||
// determine the cloud service in the background
|
||||
cloudDetector.detectCloudService();
|
||||
|
||||
const collector = usageCollection.makeUsageCollector<Usage | undefined>({
|
||||
type: 'cloud_provider',
|
||||
isReady: () => Boolean(cloudDetector.getCloudDetails()),
|
||||
async fetch() {
|
||||
const details = cloudDetector.getCloudDetails();
|
||||
if (!details) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
name: details.name,
|
||||
vm_type: details.vm_type,
|
||||
region: details.region,
|
||||
zone: details.zone,
|
||||
};
|
||||
},
|
||||
schema: {
|
||||
name: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The name of the cloud provider',
|
||||
},
|
||||
},
|
||||
vm_type: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The VM instance type',
|
||||
},
|
||||
},
|
||||
region: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The cloud provider region',
|
||||
},
|
||||
},
|
||||
zone: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The availability zone within the region',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
usageCollection.registerCollector(collector);
|
||||
}
|
|
@ -0,0 +1,311 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import type { Request, RequestOptions } from './cloud_service';
|
||||
import { AWSCloudService, AWSResponse } from './aws';
|
||||
|
||||
type Callback = (err: unknown, res: unknown) => void;
|
||||
|
||||
const AWS = new AWSCloudService();
|
||||
|
||||
describe('AWS', () => {
|
||||
const expectedFilenames = ['/sys/hypervisor/uuid', '/sys/devices/virtual/dmi/id/product_uuid'];
|
||||
const expectedEncoding = 'utf8';
|
||||
// mixed case to ensure we check for ec2 after lowercasing
|
||||
const ec2Uuid = 'eC2abcdef-ghijk\n';
|
||||
const ec2FileSystem = {
|
||||
readFile: (filename: string, encoding: string, callback: Callback) => {
|
||||
expect(expectedFilenames).toContain(filename);
|
||||
expect(encoding).toEqual(expectedEncoding);
|
||||
|
||||
callback(null, ec2Uuid);
|
||||
},
|
||||
} as typeof fs;
|
||||
|
||||
it('is named "aws"', () => {
|
||||
expect(AWS.getName()).toEqual('aws');
|
||||
});
|
||||
|
||||
describe('_checkIfService', () => {
|
||||
it('handles expected response', async () => {
|
||||
const id = 'abcdef';
|
||||
const request = ((req: RequestOptions, callback: Callback) => {
|
||||
expect(req.method).toEqual('GET');
|
||||
expect(req.uri).toEqual(
|
||||
'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document'
|
||||
);
|
||||
expect(req.json).toEqual(true);
|
||||
|
||||
const body = `{"instanceId": "${id}","availabilityZone":"us-fake-2c", "imageId" : "ami-6df1e514"}`;
|
||||
|
||||
callback(null, { statusCode: 200, body });
|
||||
}) as Request;
|
||||
// ensure it does not use the fs to trump the body
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._checkIfService(request);
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id,
|
||||
region: undefined,
|
||||
vm_type: undefined,
|
||||
zone: 'us-fake-2c',
|
||||
metadata: {
|
||||
imageId: 'ami-6df1e514',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles request without a usable body by downgrading to UUID detection', async () => {
|
||||
const request = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, { statusCode: 404 })) as Request;
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._checkIfService(request);
|
||||
|
||||
expect(response.isConfirmed()).toBe(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
vm_type: undefined,
|
||||
zone: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles request failure by downgrading to UUID detection', async () => {
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(new Error('expected: request failed'), null)) as Request;
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._checkIfService(failedRequest);
|
||||
|
||||
expect(response.isConfirmed()).toBe(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
vm_type: undefined,
|
||||
zone: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles not running on AWS', async () => {
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, null)) as Request;
|
||||
const awsIgnoredFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: true,
|
||||
});
|
||||
|
||||
const response = await awsIgnoredFileSystem._checkIfService(failedRequest);
|
||||
|
||||
expect(response.getName()).toEqual(AWS.getName());
|
||||
expect(response.isConfirmed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBody', () => {
|
||||
it('parses object in expected format', () => {
|
||||
const body: AWSResponse = {
|
||||
devpayProductCodes: null,
|
||||
privateIp: '10.0.0.38',
|
||||
availabilityZone: 'us-west-2c',
|
||||
version: '2010-08-31',
|
||||
instanceId: 'i-0c7a5b7590a4d811c',
|
||||
billingProducts: null,
|
||||
instanceType: 't2.micro',
|
||||
accountId: '1234567890',
|
||||
architecture: 'x86_64',
|
||||
kernelId: null,
|
||||
ramdiskId: null,
|
||||
imageId: 'ami-6df1e514',
|
||||
pendingTime: '2017-07-06T02:09:12Z',
|
||||
region: 'us-west-2',
|
||||
marketplaceProductCodes: null,
|
||||
};
|
||||
|
||||
const response = AWSCloudService.parseBody(AWS.getName(), body)!;
|
||||
expect(response).not.toBeNull();
|
||||
|
||||
expect(response.getName()).toEqual(AWS.getName());
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: 'aws',
|
||||
id: 'i-0c7a5b7590a4d811c',
|
||||
vm_type: 't2.micro',
|
||||
region: 'us-west-2',
|
||||
zone: 'us-west-2c',
|
||||
metadata: {
|
||||
version: '2010-08-31',
|
||||
architecture: 'x86_64',
|
||||
kernelId: null,
|
||||
marketplaceProductCodes: null,
|
||||
ramdiskId: null,
|
||||
imageId: 'ami-6df1e514',
|
||||
pendingTime: '2017-07-06T02:09:12Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unexpected response body', () => {
|
||||
// @ts-expect-error
|
||||
expect(AWSCloudService.parseBody(AWS.getName(), undefined)).toBe(null);
|
||||
// @ts-expect-error
|
||||
expect(AWSCloudService.parseBody(AWS.getName(), null)).toBe(null);
|
||||
// @ts-expect-error
|
||||
expect(AWSCloudService.parseBody(AWS.getName(), {})).toBe(null);
|
||||
// @ts-expect-error
|
||||
expect(AWSCloudService.parseBody(AWS.getName(), { privateIp: 'a.b.c.d' })).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_tryToDetectUuid', () => {
|
||||
describe('checks the file system for UUID if not Windows', () => {
|
||||
it('checks /sys/hypervisor/uuid', async () => {
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: {
|
||||
readFile: (filename: string, encoding: string, callback: Callback) => {
|
||||
expect(expectedFilenames).toContain(filename);
|
||||
expect(encoding).toEqual(expectedEncoding);
|
||||
|
||||
callback(null, ec2Uuid);
|
||||
},
|
||||
} as typeof fs,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
zone: undefined,
|
||||
vm_type: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('checks /sys/devices/virtual/dmi/id/product_uuid', async () => {
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: {
|
||||
readFile: (filename: string, encoding: string, callback: Callback) => {
|
||||
expect(expectedFilenames).toContain(filename);
|
||||
expect(encoding).toEqual(expectedEncoding);
|
||||
|
||||
callback(null, ec2Uuid);
|
||||
},
|
||||
} as typeof fs,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
zone: undefined,
|
||||
vm_type: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns confirmed if only one file exists', async () => {
|
||||
let callCount = 0;
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: {
|
||||
readFile: (filename: string, encoding: string, callback: Callback) => {
|
||||
if (callCount === 0) {
|
||||
callCount++;
|
||||
throw new Error('oops');
|
||||
}
|
||||
callback(null, ec2Uuid);
|
||||
},
|
||||
} as typeof fs,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
zone: undefined,
|
||||
vm_type: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unconfirmed if all files return errors', async () => {
|
||||
const awsFailedFileSystem = new AWSCloudService({
|
||||
_fs: ({
|
||||
readFile: () => {
|
||||
throw new Error('oops');
|
||||
},
|
||||
} as unknown) as typeof fs,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsFailedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores UUID if it does not start with ec2', async () => {
|
||||
const notEC2FileSystem = {
|
||||
readFile: (filename: string, encoding: string, callback: Callback) => {
|
||||
expect(expectedFilenames).toContain(filename);
|
||||
expect(encoding).toEqual(expectedEncoding);
|
||||
|
||||
callback(null, 'notEC2');
|
||||
},
|
||||
} as typeof fs;
|
||||
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: notEC2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(false);
|
||||
});
|
||||
|
||||
it('does NOT check the file system for UUID on Windows', async () => {
|
||||
const awsUncheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: true,
|
||||
});
|
||||
|
||||
const response = await awsUncheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { get, isString, omit } from 'lodash';
|
||||
import { promisify } from 'util';
|
||||
import { CloudService, CloudServiceOptions, Request, RequestOptions } from './cloud_service';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
|
||||
// We explicitly call out the version, 2016-09-02, rather than 'latest' to avoid unexpected changes
|
||||
const SERVICE_ENDPOINT = 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document';
|
||||
|
||||
/** @internal */
|
||||
export interface AWSResponse {
|
||||
accountId: string;
|
||||
architecture: string;
|
||||
availabilityZone: string;
|
||||
billingProducts: unknown;
|
||||
devpayProductCodes: unknown;
|
||||
marketplaceProductCodes: unknown;
|
||||
imageId: string;
|
||||
instanceId: string;
|
||||
instanceType: string;
|
||||
kernelId: unknown;
|
||||
pendingTime: string;
|
||||
privateIp: string;
|
||||
ramdiskId: unknown;
|
||||
region: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and loads the service metadata for an Amazon Web Service VM if it is available.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class AWSCloudService extends CloudService {
|
||||
private readonly _isWindows: boolean;
|
||||
private readonly _fs: typeof fs;
|
||||
|
||||
/**
|
||||
* Parse the AWS response, if possible.
|
||||
*
|
||||
* Example payload:
|
||||
* {
|
||||
* "accountId" : "1234567890",
|
||||
* "architecture" : "x86_64",
|
||||
* "availabilityZone" : "us-west-2c",
|
||||
* "billingProducts" : null,
|
||||
* "devpayProductCodes" : null,
|
||||
* "imageId" : "ami-6df1e514",
|
||||
* "instanceId" : "i-0c7a5b7590a4d811c",
|
||||
* "instanceType" : "t2.micro",
|
||||
* "kernelId" : null,
|
||||
* "pendingTime" : "2017-07-06T02:09:12Z",
|
||||
* "privateIp" : "10.0.0.38",
|
||||
* "ramdiskId" : null,
|
||||
* "region" : "us-west-2"
|
||||
* "version" : "2010-08-31",
|
||||
* }
|
||||
*/
|
||||
static parseBody(name: string, body: AWSResponse): CloudServiceResponse | null {
|
||||
const id: string | undefined = get(body, 'instanceId');
|
||||
const vmType: string | undefined = get(body, 'instanceType');
|
||||
const region: string | undefined = get(body, 'region');
|
||||
const zone: string | undefined = get(body, 'availabilityZone');
|
||||
const metadata = omit(body, [
|
||||
// remove keys we already have
|
||||
'instanceId',
|
||||
'instanceType',
|
||||
'region',
|
||||
'availabilityZone',
|
||||
// remove keys that give too much detail
|
||||
'accountId',
|
||||
'billingProducts',
|
||||
'devpayProductCodes',
|
||||
'privateIp',
|
||||
]);
|
||||
|
||||
// ensure we actually have some data
|
||||
if (id || vmType || region || zone) {
|
||||
return new CloudServiceResponse(name, true, { id, vmType, region, zone, metadata });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor(options: CloudServiceOptions = {}) {
|
||||
super('aws', options);
|
||||
|
||||
// Allow the file system handler to be swapped out for tests
|
||||
const { _fs = fs, _isWindows = process.platform.startsWith('win') } = options;
|
||||
|
||||
this._fs = _fs;
|
||||
this._isWindows = _isWindows;
|
||||
}
|
||||
|
||||
async _checkIfService(request: Request) {
|
||||
const req: RequestOptions = {
|
||||
method: 'GET',
|
||||
uri: SERVICE_ENDPOINT,
|
||||
json: true,
|
||||
};
|
||||
|
||||
return promisify(request)(req)
|
||||
.then((response) =>
|
||||
this._parseResponse(response.body, (body) =>
|
||||
AWSCloudService.parseBody(this.getName(), body)
|
||||
)
|
||||
)
|
||||
.catch(() => this._tryToDetectUuid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load the UUID by checking `/sys/hypervisor/uuid`.
|
||||
*
|
||||
* This is a fallback option if the metadata service is unavailable for some reason.
|
||||
*/
|
||||
_tryToDetectUuid() {
|
||||
// Windows does not have an easy way to check
|
||||
if (!this._isWindows) {
|
||||
const pathsToCheck = ['/sys/hypervisor/uuid', '/sys/devices/virtual/dmi/id/product_uuid'];
|
||||
const promises = pathsToCheck.map((path) => promisify(this._fs.readFile)(path, 'utf8'));
|
||||
|
||||
return Promise.allSettled(promises).then((responses) => {
|
||||
for (const response of responses) {
|
||||
let uuid;
|
||||
if (response.status === 'fulfilled' && isString(response.value)) {
|
||||
// Some AWS APIs return it lowercase (like the file did in testing), while others return it uppercase
|
||||
uuid = response.value.trim().toLowerCase();
|
||||
|
||||
// There is a small chance of a false positive here in the unlikely event that a uuid which doesn't
|
||||
// belong to ec2 happens to be generated with `ec2` as the first three characters.
|
||||
if (uuid.startsWith('ec2')) {
|
||||
return new CloudServiceResponse(this._name, true, { id: uuid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this._createUnconfirmedResponse();
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(this._createUnconfirmedResponse());
|
||||
}
|
||||
}
|
|
@ -1,11 +1,17 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { AZURE } from './azure';
|
||||
import type { Request, RequestOptions } from './cloud_service';
|
||||
import { AzureCloudService } from './azure';
|
||||
|
||||
type Callback = (err: unknown, res: unknown) => void;
|
||||
|
||||
const AZURE = new AzureCloudService();
|
||||
|
||||
describe('Azure', () => {
|
||||
it('is named "azure"', () => {
|
||||
|
@ -15,16 +21,16 @@ describe('Azure', () => {
|
|||
describe('_checkIfService', () => {
|
||||
it('handles expected response', async () => {
|
||||
const id = 'abcdef';
|
||||
const request = (req, callback) => {
|
||||
const request = ((req: RequestOptions, callback: Callback) => {
|
||||
expect(req.method).toEqual('GET');
|
||||
expect(req.uri).toEqual('http://169.254.169.254/metadata/instance?api-version=2017-04-02');
|
||||
expect(req.headers.Metadata).toEqual('true');
|
||||
expect(req.headers?.Metadata).toEqual('true');
|
||||
expect(req.json).toEqual(true);
|
||||
|
||||
const body = `{"compute":{"vmId": "${id}","location":"fakeus","availabilityZone":"fakeus-2"}}`;
|
||||
|
||||
callback(null, { statusCode: 200, body }, body);
|
||||
};
|
||||
callback(null, { statusCode: 200, body });
|
||||
}) as Request;
|
||||
const response = await AZURE._checkIfService(request);
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
|
@ -43,39 +49,30 @@ describe('Azure', () => {
|
|||
// NOTE: the CloudService method, checkIfService, catches the errors that follow
|
||||
it('handles not running on Azure with error by rethrowing it', async () => {
|
||||
const someError = new Error('expected: request failed');
|
||||
const failedRequest = (_req, callback) => callback(someError, null);
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(someError, null)) as Request;
|
||||
|
||||
try {
|
||||
expect(async () => {
|
||||
await AZURE._checkIfService(failedRequest);
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(someError.message);
|
||||
}
|
||||
}).rejects.toThrowError(someError.message);
|
||||
});
|
||||
|
||||
it('handles not running on Azure with 404 response by throwing error', async () => {
|
||||
const failedRequest = (_req, callback) => callback(null, { statusCode: 404 });
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, { statusCode: 404 })) as Request;
|
||||
|
||||
try {
|
||||
expect(async () => {
|
||||
await AZURE._checkIfService(failedRequest);
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
} catch (ignoredErr) {
|
||||
// ignored
|
||||
}
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`);
|
||||
});
|
||||
|
||||
it('handles not running on Azure with unexpected response by throwing error', async () => {
|
||||
const failedRequest = (_req, callback) => callback(null, null);
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, null)) as Request;
|
||||
|
||||
try {
|
||||
expect(async () => {
|
||||
await AZURE._checkIfService(failedRequest);
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
} catch (ignoredErr) {
|
||||
// ignored
|
||||
}
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -122,7 +119,8 @@ describe('Azure', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const response = AZURE._parseBody(body);
|
||||
const response = AzureCloudService.parseBody(AZURE.getName(), body)!;
|
||||
expect(response).not.toBeNull();
|
||||
|
||||
expect(response.getName()).toEqual(AZURE.getName());
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
|
@ -174,7 +172,8 @@ describe('Azure', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const response = AZURE._parseBody(body);
|
||||
const response = AzureCloudService.parseBody(AZURE.getName(), body)!;
|
||||
expect(response).not.toBeNull();
|
||||
|
||||
expect(response.getName()).toEqual(AZURE.getName());
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
|
@ -191,10 +190,14 @@ describe('Azure', () => {
|
|||
});
|
||||
|
||||
it('ignores unexpected response body', () => {
|
||||
expect(AZURE._parseBody(undefined)).toBe(null);
|
||||
expect(AZURE._parseBody(null)).toBe(null);
|
||||
expect(AZURE._parseBody({})).toBe(null);
|
||||
expect(AZURE._parseBody({ privateIp: 'a.b.c.d' })).toBe(null);
|
||||
// @ts-expect-error
|
||||
expect(AzureCloudService.parseBody(AZURE.getName(), undefined)).toBe(null);
|
||||
// @ts-expect-error
|
||||
expect(AzureCloudService.parseBody(AZURE.getName(), null)).toBe(null);
|
||||
// @ts-expect-error
|
||||
expect(AzureCloudService.parseBody(AZURE.getName(), {})).toBe(null);
|
||||
// @ts-expect-error
|
||||
expect(AzureCloudService.parseBody(AZURE.getName(), { privateIp: 'a.b.c.d' })).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { get, omit } from 'lodash';
|
||||
import { promisify } from 'util';
|
||||
import { CloudService, Request } from './cloud_service';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
|
||||
// 2017-04-02 is the first GA release of this API
|
||||
const SERVICE_ENDPOINT = 'http://169.254.169.254/metadata/instance?api-version=2017-04-02';
|
||||
|
||||
interface AzureResponse {
|
||||
compute?: Record<string, string>;
|
||||
network: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and loads the service metadata for an Azure VM if it is available.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class AzureCloudService extends CloudService {
|
||||
/**
|
||||
* Parse the Azure response, if possible.
|
||||
*
|
||||
* Azure VMs created using the "classic" method, as opposed to the resource manager,
|
||||
* do not provide a "compute" field / object. However, both report the "network" field / object.
|
||||
*
|
||||
* Example payload (with network object ignored):
|
||||
* {
|
||||
* "compute": {
|
||||
* "location": "eastus",
|
||||
* "name": "my-ubuntu-vm",
|
||||
* "offer": "UbuntuServer",
|
||||
* "osType": "Linux",
|
||||
* "platformFaultDomain": "0",
|
||||
* "platformUpdateDomain": "0",
|
||||
* "publisher": "Canonical",
|
||||
* "sku": "16.04-LTS",
|
||||
* "version": "16.04.201706191",
|
||||
* "vmId": "d4c57456-2b3b-437a-9f1f-7082cfce02d4",
|
||||
* "vmSize": "Standard_A1"
|
||||
* },
|
||||
* "network": {
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
static parseBody(name: string, body: AzureResponse): CloudServiceResponse | null {
|
||||
const compute: Record<string, string> | undefined = get(body, 'compute');
|
||||
const id = get<Record<string, string>, string>(compute, 'vmId');
|
||||
const vmType = get<Record<string, string>, string>(compute, 'vmSize');
|
||||
const region = get<Record<string, string>, string>(compute, 'location');
|
||||
|
||||
// remove keys that we already have; explicitly undefined so we don't send it when empty
|
||||
const metadata = compute ? omit(compute, ['vmId', 'vmSize', 'location']) : undefined;
|
||||
|
||||
// we don't actually use network, but we check for its existence to see if this is a response from Azure
|
||||
const network = get(body, 'network');
|
||||
|
||||
// ensure we actually have some data
|
||||
if (id || vmType || region) {
|
||||
return new CloudServiceResponse(name, true, { id, vmType, region, metadata });
|
||||
} else if (network) {
|
||||
// classic-managed VMs in Azure don't provide compute so we highlight the lack of info
|
||||
return new CloudServiceResponse(name, true, { metadata: { classic: true } });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor(options = {}) {
|
||||
super('azure', options);
|
||||
}
|
||||
|
||||
async _checkIfService(request: Request) {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
uri: SERVICE_ENDPOINT,
|
||||
headers: {
|
||||
// Azure requires this header
|
||||
Metadata: 'true',
|
||||
},
|
||||
json: true,
|
||||
};
|
||||
|
||||
const response = await promisify(request)(req);
|
||||
|
||||
// Note: there is no fallback option for Azure
|
||||
if (!response || response.statusCode === 404) {
|
||||
throw new Error('Azure request failed');
|
||||
}
|
||||
|
||||
return this._parseResponse(response.body, (body) =>
|
||||
AzureCloudService.parseBody(this.getName(), body)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
const create = () => {
|
||||
const mock = {
|
||||
detectCloudService: jest.fn(),
|
||||
getCloudDetails: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
};
|
||||
|
||||
export const cloudDetectorMock = { create };
|
|
@ -1,11 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { CloudDetector } from './cloud_detector';
|
||||
import type { CloudService } from './cloud_service';
|
||||
|
||||
describe('CloudDetector', () => {
|
||||
const cloudService1 = {
|
||||
|
@ -28,8 +30,10 @@ describe('CloudDetector', () => {
|
|||
};
|
||||
},
|
||||
};
|
||||
// this service is theoretically a better match for the current server, but order dictates that it should
|
||||
// never be checked (at least until we have some sort of "confidence" metric returned, if we ever run into this problem)
|
||||
// this service is theoretically a better match for the current server,
|
||||
// but order dictates that it should never be checked (at least until
|
||||
// we have some sort of "confidence" metric returned, if we ever run
|
||||
// into this problem)
|
||||
const cloudService4 = {
|
||||
checkIfService: () => {
|
||||
return {
|
||||
|
@ -40,7 +44,12 @@ describe('CloudDetector', () => {
|
|||
};
|
||||
},
|
||||
};
|
||||
const cloudServices = [cloudService1, cloudService2, cloudService3, cloudService4];
|
||||
const cloudServices = ([
|
||||
cloudService1,
|
||||
cloudService2,
|
||||
cloudService3,
|
||||
cloudService4,
|
||||
] as unknown) as CloudService[];
|
||||
|
||||
describe('getCloudDetails', () => {
|
||||
it('returns undefined by default', () => {
|
||||
|
@ -51,35 +60,34 @@ describe('CloudDetector', () => {
|
|||
});
|
||||
|
||||
describe('detectCloudService', () => {
|
||||
it('awaits _getCloudService', async () => {
|
||||
it('returns first match', async () => {
|
||||
const detector = new CloudDetector({ cloudServices });
|
||||
|
||||
expect(detector.getCloudDetails()).toBe(undefined);
|
||||
expect(detector.getCloudDetails()).toBeUndefined();
|
||||
await detector.detectCloudService();
|
||||
expect(detector.getCloudDetails()).toEqual({ name: 'good-match' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getCloudService', () => {
|
||||
it('returns first match', async () => {
|
||||
const detector = new CloudDetector();
|
||||
|
||||
// note: should never use better-match
|
||||
expect(await detector._getCloudService(cloudServices)).toEqual({ name: 'good-match' });
|
||||
expect(detector.getCloudDetails()).toEqual({ name: 'good-match' });
|
||||
});
|
||||
|
||||
it('returns undefined if none match', async () => {
|
||||
const detector = new CloudDetector();
|
||||
const services = ([cloudService1, cloudService2] as unknown) as CloudService[];
|
||||
|
||||
expect(await detector._getCloudService([cloudService1, cloudService2])).toBe(undefined);
|
||||
expect(await detector._getCloudService([])).toBe(undefined);
|
||||
const detector1 = new CloudDetector({ cloudServices: services });
|
||||
await detector1.detectCloudService();
|
||||
expect(detector1.getCloudDetails()).toBeUndefined();
|
||||
|
||||
const detector2 = new CloudDetector({ cloudServices: [] });
|
||||
await detector2.detectCloudService();
|
||||
expect(detector2.getCloudDetails()).toBeUndefined();
|
||||
});
|
||||
|
||||
// this is already tested above, but this just tests it explicitly
|
||||
it('ignores exceptions from cloud services', async () => {
|
||||
const detector = new CloudDetector();
|
||||
const services = ([cloudService2] as unknown) as CloudService[];
|
||||
const detector = new CloudDetector({ cloudServices: services });
|
||||
|
||||
expect(await detector._getCloudService([cloudService2])).toBe(undefined);
|
||||
await detector.detectCloudService();
|
||||
expect(detector.getCloudDetails()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { CloudService } from './cloud_service';
|
||||
import type { CloudServiceResponseJson } from './cloud_response';
|
||||
|
||||
import { AWSCloudService } from './aws';
|
||||
import { AzureCloudService } from './azure';
|
||||
import { GCPCloudService } from './gcp';
|
||||
|
||||
const SUPPORTED_SERVICES = [AWSCloudService, AzureCloudService, GCPCloudService];
|
||||
|
||||
interface CloudDetectorOptions {
|
||||
cloudServices?: CloudService[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CloudDetector` can be used to asynchronously detect the
|
||||
* cloud service that Kibana is running within.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class CloudDetector {
|
||||
private readonly cloudServices: CloudService[];
|
||||
private cloudDetails?: CloudServiceResponseJson;
|
||||
|
||||
constructor(options: CloudDetectorOptions = {}) {
|
||||
this.cloudServices =
|
||||
options.cloudServices ?? SUPPORTED_SERVICES.map((Service) => new Service());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any cloud details that we have detected.
|
||||
*/
|
||||
getCloudDetails() {
|
||||
return this.cloudDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously detect the cloud service.
|
||||
*
|
||||
* Callers are _not_ expected to await this method, which allows the
|
||||
* caller to trigger the lookup and then simply use it whenever we
|
||||
* determine it.
|
||||
*/
|
||||
async detectCloudService() {
|
||||
this.cloudDetails = await this.getCloudService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check every cloud service until the first one reports success from detection.
|
||||
*/
|
||||
private async getCloudService() {
|
||||
// check each service until we find one that is confirmed to match;
|
||||
// order is assumed to matter
|
||||
for (const service of this.cloudServices) {
|
||||
try {
|
||||
const serviceResponse = await service.checkIfService();
|
||||
|
||||
if (serviceResponse.isConfirmed()) {
|
||||
return serviceResponse.toJSON();
|
||||
}
|
||||
} catch (ignoredError) {
|
||||
// ignored until we make wider use of this in the UI
|
||||
}
|
||||
}
|
||||
|
||||
// explicitly undefined rather than null so that it can be ignored in JSON
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { CloudServiceResponse } from './cloud_response';
|
|
@ -1,36 +1,63 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
interface CloudServiceResponseOptions {
|
||||
id?: string;
|
||||
vmType?: string;
|
||||
region?: string;
|
||||
zone?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CloudServiceResponseJson {
|
||||
name: string;
|
||||
id?: string;
|
||||
vm_type?: string;
|
||||
region?: string;
|
||||
zone?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code CloudServiceResponse} represents a single response from any individual {@code CloudService}.
|
||||
* Represents a single response from any individual CloudService.
|
||||
*/
|
||||
export class CloudServiceResponse {
|
||||
private readonly _name: string;
|
||||
private readonly _confirmed: boolean;
|
||||
private readonly _id?: string;
|
||||
private readonly _vmType?: string;
|
||||
private readonly _region?: string;
|
||||
private readonly _zone?: string;
|
||||
private readonly _metadata?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Create an unconfirmed {@code CloudServiceResponse} by the {@code name}.
|
||||
*
|
||||
* @param {String} name The name of the {@code CloudService}.
|
||||
* @return {CloudServiceResponse} Never {@code null}.
|
||||
* Create an unconfirmed CloudServiceResponse by the name.
|
||||
*/
|
||||
static unconfirmed(name) {
|
||||
static unconfirmed(name: string) {
|
||||
return new CloudServiceResponse(name, false, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@code CloudServiceResponse}.
|
||||
* Create a new CloudServiceResponse.
|
||||
*
|
||||
* @param {String} name The name of the {@code CloudService}.
|
||||
* @param {Boolean} confirmed Confirmed to be the current {@code CloudService}.
|
||||
* @param {String} name The name of the CloudService.
|
||||
* @param {Boolean} confirmed Confirmed to be the current CloudService.
|
||||
* @param {String} id The optional ID of the VM (depends on the cloud service).
|
||||
* @param {String} vmType The optional type of VM (depends on the cloud service).
|
||||
* @param {String} region The optional region of the VM (depends on the cloud service).
|
||||
* @param {String} availabilityZone The optional availability zone within the region (depends on the cloud service).
|
||||
* @param {Object} metadata The optional metadata associated with the VM.
|
||||
*/
|
||||
constructor(name, confirmed, { id, vmType, region, zone, metadata }) {
|
||||
constructor(
|
||||
name: string,
|
||||
confirmed: boolean,
|
||||
{ id, vmType, region, zone, metadata }: CloudServiceResponseOptions
|
||||
) {
|
||||
this._name = name;
|
||||
this._confirmed = confirmed;
|
||||
this._id = id;
|
||||
|
@ -41,9 +68,7 @@ export class CloudServiceResponse {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the name of the {@code CloudService} associated with the current response.
|
||||
*
|
||||
* @return {String} The cloud service that created this response.
|
||||
* Get the name of the CloudService associated with the current response.
|
||||
*/
|
||||
getName() {
|
||||
return this._name;
|
||||
|
@ -51,8 +76,6 @@ export class CloudServiceResponse {
|
|||
|
||||
/**
|
||||
* Determine if the Cloud Service is confirmed to exist.
|
||||
*
|
||||
* @return {Boolean} {@code true} to indicate that Kibana is running in this cloud environment.
|
||||
*/
|
||||
isConfirmed() {
|
||||
return this._confirmed;
|
||||
|
@ -60,11 +83,8 @@ export class CloudServiceResponse {
|
|||
|
||||
/**
|
||||
* Create a plain JSON object that can be indexed that represents the response.
|
||||
*
|
||||
* @return {Object} Never {@code null} object.
|
||||
* @throws {Error} if this response is not {@code confirmed}.
|
||||
*/
|
||||
toJSON() {
|
||||
toJSON(): CloudServiceResponseJson {
|
||||
if (!this._confirmed) {
|
||||
throw new Error(`[${this._name}] is not confirmed`);
|
||||
}
|
|
@ -1,14 +1,16 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { CloudService } from './cloud_service';
|
||||
import { CloudService, Response } from './cloud_service';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
|
||||
describe('CloudService', () => {
|
||||
// @ts-expect-error Creating an instance of an abstract class for testing
|
||||
const service = new CloudService('xyz');
|
||||
|
||||
describe('getName', () => {
|
||||
|
@ -28,13 +30,9 @@ describe('CloudService', () => {
|
|||
|
||||
describe('_checkIfService', () => {
|
||||
it('throws an exception unless overridden', async () => {
|
||||
const request = jest.fn();
|
||||
|
||||
try {
|
||||
await service._checkIfService(request);
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual('not implemented');
|
||||
}
|
||||
expect(async () => {
|
||||
await service._checkIfService(undefined);
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(`"not implemented"`);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -89,42 +87,46 @@ describe('CloudService', () => {
|
|||
|
||||
describe('_parseResponse', () => {
|
||||
const body = { some: { body: {} } };
|
||||
const tryParseResponse = async (...args) => {
|
||||
try {
|
||||
await service._parseResponse(...args);
|
||||
} catch (err) {
|
||||
// expected
|
||||
return;
|
||||
}
|
||||
|
||||
expect().fail('Should throw exception');
|
||||
};
|
||||
|
||||
it('throws error upon failure to parse body as object', async () => {
|
||||
// missing body
|
||||
await tryParseResponse();
|
||||
await tryParseResponse(null);
|
||||
await tryParseResponse({});
|
||||
await tryParseResponse(123);
|
||||
await tryParseResponse('raw string');
|
||||
// malformed JSON object
|
||||
await tryParseResponse('{{}');
|
||||
expect(async () => {
|
||||
await service._parseResponse();
|
||||
}).rejects.toMatchInlineSnapshot(`undefined`);
|
||||
expect(async () => {
|
||||
await service._parseResponse(null);
|
||||
}).rejects.toMatchInlineSnapshot(`undefined`);
|
||||
expect(async () => {
|
||||
await service._parseResponse({});
|
||||
}).rejects.toMatchInlineSnapshot(`undefined`);
|
||||
expect(async () => {
|
||||
await service._parseResponse(123);
|
||||
}).rejects.toMatchInlineSnapshot(`undefined`);
|
||||
expect(async () => {
|
||||
await service._parseResponse('raw string');
|
||||
}).rejects.toMatchInlineSnapshot(`[Error: 'raw string' is not a JSON object]`);
|
||||
expect(async () => {
|
||||
await service._parseResponse('{{}');
|
||||
}).rejects.toMatchInlineSnapshot(`[Error: '{{}' is not a JSON object]`);
|
||||
});
|
||||
|
||||
it('expects unusable bodies', async () => {
|
||||
const parseBody = (parsedBody) => {
|
||||
const parseBody = (parsedBody: Response['body']) => {
|
||||
expect(parsedBody).toEqual(body);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
await tryParseResponse(JSON.stringify(body), parseBody);
|
||||
await tryParseResponse(body, parseBody);
|
||||
expect(async () => {
|
||||
await service._parseResponse(JSON.stringify(body), parseBody);
|
||||
}).rejects.toMatchInlineSnapshot(`undefined`);
|
||||
expect(async () => {
|
||||
await service._parseResponse(body, parseBody);
|
||||
}).rejects.toMatchInlineSnapshot(`undefined`);
|
||||
});
|
||||
|
||||
it('uses parsed object to create response', async () => {
|
||||
const serviceResponse = new CloudServiceResponse('a123', true, { id: 'xyz' });
|
||||
const parseBody = (parsedBody) => {
|
||||
const parseBody = (parsedBody: Response['body']) => {
|
||||
expect(parsedBody).toEqual(body);
|
||||
|
||||
return serviceResponse;
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { isObject, isString, isPlainObject } from 'lodash';
|
||||
import defaultRequest from 'request';
|
||||
import type { OptionsWithUri, Response as DefaultResponse } from 'request';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
|
||||
/** @internal */
|
||||
export type Request = typeof defaultRequest;
|
||||
|
||||
/** @internal */
|
||||
export type RequestOptions = OptionsWithUri;
|
||||
|
||||
/** @internal */
|
||||
export type Response = DefaultResponse;
|
||||
|
||||
/** @internal */
|
||||
export interface CloudServiceOptions {
|
||||
_request?: Request;
|
||||
_fs?: typeof fs;
|
||||
_isWindows?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CloudService provides a mechanism for cloud services to be checked for
|
||||
* metadata that may help to determine the best defaults and priorities.
|
||||
*/
|
||||
export abstract class CloudService {
|
||||
private readonly _request: Request;
|
||||
protected readonly _name: string;
|
||||
|
||||
constructor(name: string, options: CloudServiceOptions = {}) {
|
||||
this._name = name.toLowerCase();
|
||||
|
||||
// Allow the HTTP handler to be swapped out for tests
|
||||
const { _request = defaultRequest } = options;
|
||||
|
||||
this._request = _request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search-friendly name of the Cloud Service.
|
||||
*/
|
||||
getName() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using whatever mechanism is required by the current Cloud Service,
|
||||
* determine if Kibana is running in it and return relevant metadata.
|
||||
*/
|
||||
async checkIfService() {
|
||||
try {
|
||||
return await this._checkIfService(this._request);
|
||||
} catch (e) {
|
||||
return this._createUnconfirmedResponse();
|
||||
}
|
||||
}
|
||||
|
||||
_checkIfService(request: Request): Promise<CloudServiceResponse> {
|
||||
// should always be overridden by a subclass
|
||||
return Promise.reject(new Error('not implemented'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CloudServiceResponse that denotes that this cloud service
|
||||
* is not being used by the current machine / VM.
|
||||
*/
|
||||
_createUnconfirmedResponse() {
|
||||
return CloudServiceResponse.unconfirmed(this._name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly parse JSON.
|
||||
*/
|
||||
_stringToJson(value: string) {
|
||||
// note: this will throw an error if this is not a string
|
||||
value = value.trim();
|
||||
|
||||
try {
|
||||
const json = JSON.parse(value);
|
||||
// we don't want to return scalar values, arrays, etc.
|
||||
if (!isPlainObject(json)) {
|
||||
throw new Error('not a plain object');
|
||||
}
|
||||
return json;
|
||||
} catch (e) {
|
||||
throw new Error(`'${value}' is not a JSON object`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the response to a JSON object and attempt to parse it using the
|
||||
* parseBody function.
|
||||
*
|
||||
* If the response cannot be parsed as a JSON object, or if it fails to be
|
||||
* useful, then parseBody should return null.
|
||||
*/
|
||||
_parseResponse(
|
||||
body: Response['body'],
|
||||
parseBody?: (body: Response['body']) => CloudServiceResponse | null
|
||||
): Promise<CloudServiceResponse> {
|
||||
// parse it if necessary
|
||||
if (isString(body)) {
|
||||
try {
|
||||
body = this._stringToJson(body);
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(body) && parseBody) {
|
||||
const response = parseBody(body);
|
||||
|
||||
if (response) {
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
// use default handling
|
||||
return Promise.reject();
|
||||
}
|
||||
}
|
|
@ -1,11 +1,17 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { GCP } from './gcp';
|
||||
import type { Request, RequestOptions } from './cloud_service';
|
||||
import { GCPCloudService } from './gcp';
|
||||
|
||||
type Callback = (err: unknown, res: unknown) => void;
|
||||
|
||||
const GCP = new GCPCloudService();
|
||||
|
||||
describe('GCP', () => {
|
||||
it('is named "gcp"', () => {
|
||||
|
@ -17,30 +23,28 @@ describe('GCP', () => {
|
|||
const headers = { 'metadata-flavor': 'Google' };
|
||||
|
||||
it('handles expected responses', async () => {
|
||||
const metadata = {
|
||||
const metadata: Record<string, string> = {
|
||||
id: 'abcdef',
|
||||
'machine-type': 'projects/441331612345/machineTypes/f1-micro',
|
||||
zone: 'projects/441331612345/zones/us-fake4-c',
|
||||
};
|
||||
const request = (req, callback) => {
|
||||
const request = ((req: RequestOptions, callback: Callback) => {
|
||||
const basePath = 'http://169.254.169.254/computeMetadata/v1/instance/';
|
||||
|
||||
expect(req.method).toEqual('GET');
|
||||
expect(req.uri.startsWith(basePath)).toBe(true);
|
||||
expect(req.headers['Metadata-Flavor']).toEqual('Google');
|
||||
expect((req.uri as string).startsWith(basePath)).toBe(true);
|
||||
expect(req.headers!['Metadata-Flavor']).toEqual('Google');
|
||||
expect(req.json).toEqual(false);
|
||||
|
||||
const requestKey = req.uri.substring(basePath.length);
|
||||
const requestKey = (req.uri as string).substring(basePath.length);
|
||||
let body = null;
|
||||
|
||||
if (metadata[requestKey]) {
|
||||
body = metadata[requestKey];
|
||||
} else {
|
||||
expect().fail(`Unknown field requested [${requestKey}]`);
|
||||
}
|
||||
|
||||
callback(null, { statusCode: 200, body, headers }, body);
|
||||
};
|
||||
callback(null, { statusCode: 200, body, headers });
|
||||
}) as Request;
|
||||
const response = await GCP._checkIfService(request);
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
|
@ -56,79 +60,63 @@ describe('GCP', () => {
|
|||
|
||||
// NOTE: the CloudService method, checkIfService, catches the errors that follow
|
||||
it('handles unexpected responses', async () => {
|
||||
const request = (_req, callback) => callback(null, { statusCode: 200, headers });
|
||||
const request = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, { statusCode: 200, headers })) as Request;
|
||||
|
||||
try {
|
||||
expect(async () => {
|
||||
await GCP._checkIfService(request);
|
||||
} catch (err) {
|
||||
// ignored
|
||||
return;
|
||||
}
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(`"unrecognized responses"`);
|
||||
});
|
||||
|
||||
it('handles unexpected responses without response header', async () => {
|
||||
const body = 'xyz';
|
||||
const request = (_req, callback) => callback(null, { statusCode: 200, body }, body);
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, { statusCode: 200, body })) as Request;
|
||||
|
||||
try {
|
||||
await GCP._checkIfService(request);
|
||||
} catch (err) {
|
||||
// ignored
|
||||
return;
|
||||
}
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
expect(async () => {
|
||||
await GCP._checkIfService(failedRequest);
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(`"unrecognized responses"`);
|
||||
});
|
||||
|
||||
it('handles not running on GCP with error by rethrowing it', async () => {
|
||||
const someError = new Error('expected: request failed');
|
||||
const failedRequest = (_req, callback) => callback(someError, null);
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(someError, null)) as Request;
|
||||
|
||||
try {
|
||||
expect(async () => {
|
||||
await GCP._checkIfService(failedRequest);
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(someError.message);
|
||||
}
|
||||
}).rejects.toThrowError(someError);
|
||||
});
|
||||
|
||||
it('handles not running on GCP with 404 response by throwing error', async () => {
|
||||
const body = 'This is some random error text';
|
||||
const failedRequest = (_req, callback) =>
|
||||
callback(null, { statusCode: 404, headers, body }, body);
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, { statusCode: 404, headers, body })) as Request;
|
||||
|
||||
try {
|
||||
expect(async () => {
|
||||
await GCP._checkIfService(failedRequest);
|
||||
} catch (err) {
|
||||
// ignored
|
||||
return;
|
||||
}
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`);
|
||||
});
|
||||
|
||||
it('handles not running on GCP with unexpected response by throwing error', async () => {
|
||||
const failedRequest = (_req, callback) => callback(null, null);
|
||||
const failedRequest = ((_req: RequestOptions, callback: Callback) =>
|
||||
callback(null, null)) as Request;
|
||||
|
||||
try {
|
||||
expect(async () => {
|
||||
await GCP._checkIfService(failedRequest);
|
||||
} catch (err) {
|
||||
// ignored
|
||||
return;
|
||||
}
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_extractValue', () => {
|
||||
it('only handles strings', () => {
|
||||
// @ts-expect-error
|
||||
expect(GCP._extractValue()).toBe(undefined);
|
||||
// @ts-expect-error
|
||||
expect(GCP._extractValue(null, null)).toBe(undefined);
|
||||
// @ts-expect-error
|
||||
expect(GCP._extractValue('abc', { field: 'abcxyz' })).toBe(undefined);
|
||||
// @ts-expect-error
|
||||
expect(GCP._extractValue('abc', 1234)).toBe(undefined);
|
||||
expect(GCP._extractValue('abc/', 'abc/xyz')).toEqual('xyz');
|
||||
});
|
||||
|
@ -179,12 +167,17 @@ describe('GCP', () => {
|
|||
});
|
||||
|
||||
it('ignores unexpected response body', () => {
|
||||
// @ts-expect-error
|
||||
expect(() => GCP._combineResponses()).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => GCP._combineResponses(undefined, undefined, undefined)).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => GCP._combineResponses(null, null, null)).toThrow();
|
||||
expect(() =>
|
||||
// @ts-expect-error
|
||||
GCP._combineResponses({ id: 'x' }, { machineType: 'a' }, { zone: 'b' })
|
||||
).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => GCP._combineResponses({ privateIp: 'a.b.c.d' })).toThrow();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isString } from 'lodash';
|
||||
import { promisify } from 'util';
|
||||
import { CloudService, CloudServiceOptions, Request, Response } from './cloud_service';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
|
||||
// GCP documentation shows both 'metadata.google.internal' (mostly) and '169.254.169.254' (sometimes)
|
||||
// To bypass potential DNS changes, the IP was used because it's shared with other cloud services
|
||||
const SERVICE_ENDPOINT = 'http://169.254.169.254/computeMetadata/v1/instance';
|
||||
|
||||
/**
|
||||
* Checks and loads the service metadata for an Google Cloud Platform VM if it is available.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class GCPCloudService extends CloudService {
|
||||
constructor(options: CloudServiceOptions = {}) {
|
||||
super('gcp', options);
|
||||
}
|
||||
|
||||
_checkIfService(request: Request) {
|
||||
// we need to call GCP individually for each field we want metadata for
|
||||
const fields = ['id', 'machine-type', 'zone'];
|
||||
|
||||
const create = this._createRequestForField;
|
||||
const allRequests = fields.map((field) => promisify(request)(create(field)));
|
||||
return (
|
||||
Promise.all(allRequests)
|
||||
// Note: there is no fallback option for GCP;
|
||||
// responses are arrays containing [fullResponse, body];
|
||||
// because GCP returns plaintext, we have no way of validating
|
||||
// without using the response code.
|
||||
.then((responses) => {
|
||||
return responses.map((response) => {
|
||||
if (!response || response.statusCode === 404) {
|
||||
throw new Error('GCP request failed');
|
||||
}
|
||||
return this._extractBody(response, response.body);
|
||||
});
|
||||
})
|
||||
.then(([id, machineType, zone]) => this._combineResponses(id, machineType, zone))
|
||||
);
|
||||
}
|
||||
|
||||
_createRequestForField(field: string) {
|
||||
return {
|
||||
method: 'GET',
|
||||
uri: `${SERVICE_ENDPOINT}/${field}`,
|
||||
headers: {
|
||||
// GCP requires this header
|
||||
'Metadata-Flavor': 'Google',
|
||||
},
|
||||
// GCP does _not_ return JSON
|
||||
json: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the body if the response is valid and it came from GCP.
|
||||
*/
|
||||
_extractBody(response: Response, body?: Response['body']) {
|
||||
if (
|
||||
response?.statusCode === 200 &&
|
||||
response.headers &&
|
||||
response.headers['metadata-flavor'] === 'Google'
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the GCP responses, if possible.
|
||||
*
|
||||
* Example values for each parameter:
|
||||
*
|
||||
* vmId: '5702733457649812345'
|
||||
* machineType: 'projects/441331612345/machineTypes/f1-micro'
|
||||
* zone: 'projects/441331612345/zones/us-east4-c'
|
||||
*/
|
||||
_combineResponses(id: string, machineType: string, zone: string) {
|
||||
const vmId = isString(id) ? id.trim() : undefined;
|
||||
const vmType = this._extractValue('machineTypes/', machineType);
|
||||
const vmZone = this._extractValue('zones/', zone);
|
||||
|
||||
let region;
|
||||
|
||||
if (vmZone) {
|
||||
// converts 'us-east4-c' into 'us-east4'
|
||||
region = vmZone.substring(0, vmZone.lastIndexOf('-'));
|
||||
}
|
||||
|
||||
// ensure we actually have some data
|
||||
if (vmId || vmType || region || vmZone) {
|
||||
return new CloudServiceResponse(this._name, true, { id: vmId, vmType, region, zone: vmZone });
|
||||
}
|
||||
|
||||
throw new Error('unrecognized responses');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the useful information returned from GCP while discarding
|
||||
* unwanted account details (the project ID).
|
||||
*
|
||||
* For example, this turns something like
|
||||
* 'projects/441331612345/machineTypes/f1-micro' into 'f1-micro'.
|
||||
*/
|
||||
_extractValue(fieldPrefix: string, value: string) {
|
||||
if (isString(value)) {
|
||||
const index = value.lastIndexOf(fieldPrefix);
|
||||
|
||||
if (index !== -1) {
|
||||
return value.substring(index + fieldPrefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { CloudDetector } from './cloud_detector';
|
||||
export { CLOUD_SERVICES } from './cloud_services';
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { registerCloudProviderUsageCollector } from './cloud_provider_collector';
|
|
@ -11,6 +11,7 @@ export { registerManagementUsageCollector } from './management';
|
|||
export { registerApplicationUsageCollector } from './application_usage';
|
||||
export { registerKibanaUsageCollector } from './kibana';
|
||||
export { registerOpsStatsCollector } from './ops_stats';
|
||||
export { registerCloudProviderUsageCollector } from './cloud';
|
||||
export { registerCspCollector } from './csp';
|
||||
export { registerCoreUsageCollector } from './core';
|
||||
export { registerLocalizationUsageCollector } from './localization';
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { cloudDetectorMock } from './collectors/cloud/detector/cloud_detector.mock';
|
||||
|
||||
const mock = cloudDetectorMock.create();
|
||||
|
||||
export const cloudDetailsMock = mock.getCloudDetails;
|
||||
export const detectCloudServiceMock = mock.detectCloudService;
|
||||
|
||||
jest.doMock('./collectors/cloud/detector', () => ({
|
||||
CloudDetector: jest.fn().mockImplementation(() => mock),
|
||||
}));
|
|
@ -15,6 +15,7 @@ import {
|
|||
CollectorOptions,
|
||||
createUsageCollectionSetupMock,
|
||||
} from '../../usage_collection/server/usage_collection.mock';
|
||||
import { cloudDetailsMock } from './index.test.mocks';
|
||||
|
||||
import { plugin } from './';
|
||||
|
||||
|
@ -33,6 +34,10 @@ describe('kibana_usage_collection', () => {
|
|||
return createUsageCollectionSetupMock().makeStatsCollector(opts);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cloudDetailsMock.mockClear();
|
||||
});
|
||||
|
||||
test('Runs the setup method without issues', () => {
|
||||
const coreSetup = coreMock.createSetup();
|
||||
|
||||
|
@ -50,6 +55,12 @@ describe('kibana_usage_collection', () => {
|
|||
coreStart.uiSettings.asScopedToClient.mockImplementation(() =>
|
||||
uiSettingsServiceMock.createClient()
|
||||
);
|
||||
cloudDetailsMock.mockReturnValueOnce({
|
||||
name: 'my-cloud',
|
||||
vm_type: 'big',
|
||||
region: 'my-home',
|
||||
zone: 'my-home-office',
|
||||
});
|
||||
|
||||
expect(pluginInstance.start(coreStart)).toBe(undefined);
|
||||
usageCollectors.forEach(({ isReady }) => {
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
registerManagementUsageCollector,
|
||||
registerOpsStatsCollector,
|
||||
registerUiMetricUsageCollector,
|
||||
registerCloudProviderUsageCollector,
|
||||
registerCspCollector,
|
||||
registerCoreUsageCollector,
|
||||
registerLocalizationUsageCollector,
|
||||
|
@ -102,6 +103,7 @@ export class KibanaUsageCollectionPlugin implements Plugin {
|
|||
registerType,
|
||||
getSavedObjectsClient
|
||||
);
|
||||
registerCloudProviderUsageCollector(usageCollection);
|
||||
registerCspCollector(usageCollection, coreSetup.http);
|
||||
registerCoreUsageCollector(usageCollection, getCoreUsageDataService);
|
||||
registerLocalizationUsageCollector(usageCollection, coreSetup.i18n);
|
||||
|
|
|
@ -6445,6 +6445,34 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"cloud_provider": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "The name of the cloud provider"
|
||||
}
|
||||
},
|
||||
"vm_type": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "The VM instance type"
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "The cloud provider region"
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "The availability zone within the region"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"core": {
|
||||
"properties": {
|
||||
"config": {
|
||||
|
|
|
@ -97,23 +97,6 @@ export const CALCULATE_DURATION_UNTIL = 'until';
|
|||
*/
|
||||
export const ML_SUPPORTED_LICENSES = ['trial', 'platinum', 'enterprise'];
|
||||
|
||||
/**
|
||||
* Metadata service URLs for the different cloud services that have constant URLs (e.g., unlike GCP, which is a constant prefix).
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const CLOUD_METADATA_SERVICES = {
|
||||
// We explicitly call out the version, 2016-09-02, rather than 'latest' to avoid unexpected changes
|
||||
AWS_URL: 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document',
|
||||
|
||||
// 2017-04-02 is the first GA release of this API
|
||||
AZURE_URL: 'http://169.254.169.254/metadata/instance?api-version=2017-04-02',
|
||||
|
||||
// GCP documentation shows both 'metadata.google.internal' (mostly) and '169.254.169.254' (sometimes)
|
||||
// To bypass potential DNS changes, the IP was used because it's shared with other cloud services
|
||||
GCP_URL_PREFIX: 'http://169.254.169.254/computeMetadata/v1/instance',
|
||||
};
|
||||
|
||||
/**
|
||||
* Constants used by Logstash monitoring code
|
||||
*/
|
||||
|
|
|
@ -1,127 +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 { get, isString, omit } from 'lodash';
|
||||
import { promisify } from 'util';
|
||||
import { CloudService } from './cloud_service';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
import fs from 'fs';
|
||||
import { CLOUD_METADATA_SERVICES } from '../../common/constants';
|
||||
|
||||
/**
|
||||
* {@code AWSCloudService} will check and load the service metadata for an Amazon Web Service VM if it is available.
|
||||
*
|
||||
* This is exported for testing purposes. Use the {@code AWS} singleton.
|
||||
*/
|
||||
export class AWSCloudService extends CloudService {
|
||||
constructor(options = {}) {
|
||||
super('aws', options);
|
||||
|
||||
// Allow the file system handler to be swapped out for tests
|
||||
const { _fs = fs, _isWindows = process.platform.startsWith('win') } = options;
|
||||
|
||||
this._fs = _fs;
|
||||
this._isWindows = _isWindows;
|
||||
}
|
||||
|
||||
_checkIfService(request) {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
uri: CLOUD_METADATA_SERVICES.AWS_URL,
|
||||
json: true,
|
||||
};
|
||||
|
||||
return (
|
||||
promisify(request)(req)
|
||||
.then((response) => this._parseResponse(response.body, (body) => this._parseBody(body)))
|
||||
// fall back to file detection
|
||||
.catch(() => this._tryToDetectUuid())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the AWS response, if possible. Example payload (with fake accountId value):
|
||||
*
|
||||
* {
|
||||
* "devpayProductCodes" : null,
|
||||
* "privateIp" : "10.0.0.38",
|
||||
* "availabilityZone" : "us-west-2c",
|
||||
* "version" : "2010-08-31",
|
||||
* "instanceId" : "i-0c7a5b7590a4d811c",
|
||||
* "billingProducts" : null,
|
||||
* "instanceType" : "t2.micro",
|
||||
* "imageId" : "ami-6df1e514",
|
||||
* "accountId" : "1234567890",
|
||||
* "architecture" : "x86_64",
|
||||
* "kernelId" : null,
|
||||
* "ramdiskId" : null,
|
||||
* "pendingTime" : "2017-07-06T02:09:12Z",
|
||||
* "region" : "us-west-2"
|
||||
* }
|
||||
*
|
||||
* @param {Object} body The response from the VM web service.
|
||||
* @return {CloudServiceResponse} {@code null} if not confirmed. Otherwise the response.
|
||||
*/
|
||||
_parseBody(body) {
|
||||
const id = get(body, 'instanceId');
|
||||
const vmType = get(body, 'instanceType');
|
||||
const region = get(body, 'region');
|
||||
const zone = get(body, 'availabilityZone');
|
||||
const metadata = omit(body, [
|
||||
// remove keys we already have
|
||||
'instanceId',
|
||||
'instanceType',
|
||||
'region',
|
||||
'availabilityZone',
|
||||
// remove keys that give too much detail
|
||||
'accountId',
|
||||
'billingProducts',
|
||||
'devpayProductCodes',
|
||||
'privateIp',
|
||||
]);
|
||||
|
||||
// ensure we actually have some data
|
||||
if (id || vmType || region || zone) {
|
||||
return new CloudServiceResponse(this._name, true, { id, vmType, region, zone, metadata });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load the UUID by checking `/sys/hypervisor/uuid`. This is a fallback option if the metadata service is
|
||||
* unavailable for some reason.
|
||||
*
|
||||
* @return {Promise} Never {@code null} {@code CloudServiceResponse}.
|
||||
*/
|
||||
_tryToDetectUuid() {
|
||||
// Windows does not have an easy way to check
|
||||
if (!this._isWindows) {
|
||||
return promisify(this._fs.readFile)('/sys/hypervisor/uuid', 'utf8').then((uuid) => {
|
||||
if (isString(uuid)) {
|
||||
// Some AWS APIs return it lowercase (like the file did in testing), while others return it uppercase
|
||||
uuid = uuid.trim().toLowerCase();
|
||||
|
||||
if (uuid.startsWith('ec2')) {
|
||||
return new CloudServiceResponse(this._name, true, { id: uuid });
|
||||
}
|
||||
}
|
||||
|
||||
return this._createUnconfirmedResponse();
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(this._createUnconfirmedResponse());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of {@code AWSCloudService}.
|
||||
*
|
||||
* @type {AWSCloudService}
|
||||
*/
|
||||
export const AWS = new AWSCloudService();
|
|
@ -1,237 +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 { AWS, AWSCloudService } from './aws';
|
||||
|
||||
describe('AWS', () => {
|
||||
const expectedFilename = '/sys/hypervisor/uuid';
|
||||
const expectedEncoding = 'utf8';
|
||||
// mixed case to ensure we check for ec2 after lowercasing
|
||||
const ec2Uuid = 'eC2abcdef-ghijk\n';
|
||||
const ec2FileSystem = {
|
||||
readFile: (filename, encoding, callback) => {
|
||||
expect(filename).toEqual(expectedFilename);
|
||||
expect(encoding).toEqual(expectedEncoding);
|
||||
|
||||
callback(null, ec2Uuid);
|
||||
},
|
||||
};
|
||||
|
||||
it('is named "aws"', () => {
|
||||
expect(AWS.getName()).toEqual('aws');
|
||||
});
|
||||
|
||||
describe('_checkIfService', () => {
|
||||
it('handles expected response', async () => {
|
||||
const id = 'abcdef';
|
||||
const request = (req, callback) => {
|
||||
expect(req.method).toEqual('GET');
|
||||
expect(req.uri).toEqual(
|
||||
'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document'
|
||||
);
|
||||
expect(req.json).toEqual(true);
|
||||
|
||||
const body = `{"instanceId": "${id}","availabilityZone":"us-fake-2c", "imageId" : "ami-6df1e514"}`;
|
||||
|
||||
callback(null, { statusCode: 200, body }, body);
|
||||
};
|
||||
// ensure it does not use the fs to trump the body
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._checkIfService(request);
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id,
|
||||
region: undefined,
|
||||
vm_type: undefined,
|
||||
zone: 'us-fake-2c',
|
||||
metadata: {
|
||||
imageId: 'ami-6df1e514',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles request without a usable body by downgrading to UUID detection', async () => {
|
||||
const request = (_req, callback) => callback(null, { statusCode: 404 });
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._checkIfService(request);
|
||||
|
||||
expect(response.isConfirmed()).toBe(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
vm_type: undefined,
|
||||
zone: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles request failure by downgrading to UUID detection', async () => {
|
||||
const failedRequest = (_req, callback) =>
|
||||
callback(new Error('expected: request failed'), null);
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._checkIfService(failedRequest);
|
||||
|
||||
expect(response.isConfirmed()).toBe(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
vm_type: undefined,
|
||||
zone: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles not running on AWS', async () => {
|
||||
const failedRequest = (_req, callback) => callback(null, null);
|
||||
const awsIgnoredFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: true,
|
||||
});
|
||||
|
||||
const response = await awsIgnoredFileSystem._checkIfService(failedRequest);
|
||||
|
||||
expect(response.getName()).toEqual(AWS.getName());
|
||||
expect(response.isConfirmed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_parseBody', () => {
|
||||
it('parses object in expected format', () => {
|
||||
const body = {
|
||||
devpayProductCodes: null,
|
||||
privateIp: '10.0.0.38',
|
||||
availabilityZone: 'us-west-2c',
|
||||
version: '2010-08-31',
|
||||
instanceId: 'i-0c7a5b7590a4d811c',
|
||||
billingProducts: null,
|
||||
instanceType: 't2.micro',
|
||||
accountId: '1234567890',
|
||||
architecture: 'x86_64',
|
||||
kernelId: null,
|
||||
ramdiskId: null,
|
||||
imageId: 'ami-6df1e514',
|
||||
pendingTime: '2017-07-06T02:09:12Z',
|
||||
region: 'us-west-2',
|
||||
};
|
||||
|
||||
const response = AWS._parseBody(body);
|
||||
|
||||
expect(response.getName()).toEqual(AWS.getName());
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: 'aws',
|
||||
id: 'i-0c7a5b7590a4d811c',
|
||||
vm_type: 't2.micro',
|
||||
region: 'us-west-2',
|
||||
zone: 'us-west-2c',
|
||||
metadata: {
|
||||
version: '2010-08-31',
|
||||
architecture: 'x86_64',
|
||||
kernelId: null,
|
||||
ramdiskId: null,
|
||||
imageId: 'ami-6df1e514',
|
||||
pendingTime: '2017-07-06T02:09:12Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unexpected response body', () => {
|
||||
expect(AWS._parseBody(undefined)).toBe(null);
|
||||
expect(AWS._parseBody(null)).toBe(null);
|
||||
expect(AWS._parseBody({})).toBe(null);
|
||||
expect(AWS._parseBody({ privateIp: 'a.b.c.d' })).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_tryToDetectUuid', () => {
|
||||
it('checks the file system for UUID if not Windows', async () => {
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(true);
|
||||
expect(response.toJSON()).toEqual({
|
||||
name: AWS.getName(),
|
||||
id: ec2Uuid.trim().toLowerCase(),
|
||||
region: undefined,
|
||||
zone: undefined,
|
||||
vm_type: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores UUID if it does not start with ec2', async () => {
|
||||
const notEC2FileSystem = {
|
||||
readFile: (filename, encoding, callback) => {
|
||||
expect(filename).toEqual(expectedFilename);
|
||||
expect(encoding).toEqual(expectedEncoding);
|
||||
|
||||
callback(null, 'notEC2');
|
||||
},
|
||||
};
|
||||
|
||||
const awsCheckedFileSystem = new AWSCloudService({
|
||||
_fs: notEC2FileSystem,
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
const response = await awsCheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(false);
|
||||
});
|
||||
|
||||
it('does NOT check the file system for UUID on Windows', async () => {
|
||||
const awsUncheckedFileSystem = new AWSCloudService({
|
||||
_fs: ec2FileSystem,
|
||||
_isWindows: true,
|
||||
});
|
||||
|
||||
const response = await awsUncheckedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect(response.isConfirmed()).toEqual(false);
|
||||
});
|
||||
|
||||
it('does NOT handle file system exceptions', async () => {
|
||||
const fileDNE = new Error('File DNE');
|
||||
const awsFailedFileSystem = new AWSCloudService({
|
||||
_fs: {
|
||||
readFile: () => {
|
||||
throw fileDNE;
|
||||
},
|
||||
},
|
||||
_isWindows: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await awsFailedFileSystem._tryToDetectUuid();
|
||||
|
||||
expect().fail('Method should throw exception (Promise.reject)');
|
||||
} catch (err) {
|
||||
expect(err).toBe(fileDNE);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,99 +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 { get, omit } from 'lodash';
|
||||
import { promisify } from 'util';
|
||||
import { CloudService } from './cloud_service';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
import { CLOUD_METADATA_SERVICES } from '../../common/constants';
|
||||
|
||||
/**
|
||||
* {@code AzureCloudService} will check and load the service metadata for an Azure VM if it is available.
|
||||
*/
|
||||
class AzureCloudService extends CloudService {
|
||||
constructor(options = {}) {
|
||||
super('azure', options);
|
||||
}
|
||||
|
||||
_checkIfService(request) {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
uri: CLOUD_METADATA_SERVICES.AZURE_URL,
|
||||
headers: {
|
||||
// Azure requires this header
|
||||
Metadata: 'true',
|
||||
},
|
||||
json: true,
|
||||
};
|
||||
|
||||
return (
|
||||
promisify(request)(req)
|
||||
// Note: there is no fallback option for Azure
|
||||
.then((response) => {
|
||||
return this._parseResponse(response.body, (body) => this._parseBody(body));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Azure response, if possible. Example payload (with network object ignored):
|
||||
*
|
||||
* {
|
||||
* "compute": {
|
||||
* "location": "eastus",
|
||||
* "name": "my-ubuntu-vm",
|
||||
* "offer": "UbuntuServer",
|
||||
* "osType": "Linux",
|
||||
* "platformFaultDomain": "0",
|
||||
* "platformUpdateDomain": "0",
|
||||
* "publisher": "Canonical",
|
||||
* "sku": "16.04-LTS",
|
||||
* "version": "16.04.201706191",
|
||||
* "vmId": "d4c57456-2b3b-437a-9f1f-7082cfce02d4",
|
||||
* "vmSize": "Standard_A1"
|
||||
* },
|
||||
* "network": {
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Note: Azure VMs created using the "classic" method, as opposed to the resource manager,
|
||||
* do not provide a "compute" field / object. However, both report the "network" field / object.
|
||||
*
|
||||
* @param {Object} body The response from the VM web service.
|
||||
* @return {CloudServiceResponse} {@code null} for default fallback.
|
||||
*/
|
||||
_parseBody(body) {
|
||||
const compute = get(body, 'compute');
|
||||
const id = get(compute, 'vmId');
|
||||
const vmType = get(compute, 'vmSize');
|
||||
const region = get(compute, 'location');
|
||||
|
||||
// remove keys that we already have; explicitly undefined so we don't send it when empty
|
||||
const metadata = compute ? omit(compute, ['vmId', 'vmSize', 'location']) : undefined;
|
||||
|
||||
// we don't actually use network, but we check for its existence to see if this is a response from Azure
|
||||
const network = get(body, 'network');
|
||||
|
||||
// ensure we actually have some data
|
||||
if (id || vmType || region) {
|
||||
return new CloudServiceResponse(this._name, true, { id, vmType, region, metadata });
|
||||
} else if (network) {
|
||||
// classic-managed VMs in Azure don't provide compute so we highlight the lack of info
|
||||
return new CloudServiceResponse(this._name, true, { metadata: { classic: true } });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of {@code AzureCloudService}.
|
||||
*
|
||||
* @type {AzureCloudService}
|
||||
*/
|
||||
export const AZURE = new AzureCloudService();
|
|
@ -1,64 +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 { CLOUD_SERVICES } from './cloud_services';
|
||||
|
||||
/**
|
||||
* {@code CloudDetector} can be used to asynchronously detect the cloud service that Kibana is running within.
|
||||
*/
|
||||
export class CloudDetector {
|
||||
constructor(options = {}) {
|
||||
const { cloudServices = CLOUD_SERVICES } = options;
|
||||
|
||||
this._cloudServices = cloudServices;
|
||||
// Explicitly undefined. If the value is never updated, then the property will be dropped when the data is serialized.
|
||||
this._cloudDetails = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any cloud details that we have detected.
|
||||
*
|
||||
* @return {Object} {@code undefined} if unknown. Otherwise plain JSON.
|
||||
*/
|
||||
getCloudDetails() {
|
||||
return this._cloudDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously detect the cloud service.
|
||||
*
|
||||
* Callers are _not_ expected to {@code await} this method, which allows the caller to trigger the lookup and then simply use it
|
||||
* whenever we determine it.
|
||||
*/
|
||||
async detectCloudService() {
|
||||
this._cloudDetails = await this._getCloudService(this._cloudServices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check every cloud service until the first one reports success from detection.
|
||||
*
|
||||
* @param {Array} cloudServices The {@code CloudService} objects listed in priority order
|
||||
* @return {Promise} {@code undefined} if none match. Otherwise the plain JSON {@code Object} from the {@code CloudServiceResponse}.
|
||||
*/
|
||||
async _getCloudService(cloudServices) {
|
||||
// check each service until we find one that is confirmed to match; order is assumed to matter
|
||||
for (const service of cloudServices) {
|
||||
try {
|
||||
const serviceResponse = await service.checkIfService();
|
||||
|
||||
if (serviceResponse.isConfirmed()) {
|
||||
return serviceResponse.toJSON();
|
||||
}
|
||||
} catch (ignoredError) {
|
||||
// ignored until we make wider use of this in the UI
|
||||
}
|
||||
}
|
||||
|
||||
// explicitly undefined rather than null so that it can be ignored in JSON
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -1,115 +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 { isObject, isString } from 'lodash';
|
||||
import request from 'request';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
|
||||
/**
|
||||
* {@code CloudService} provides a mechanism for cloud services to be checked for metadata
|
||||
* that may help to determine the best defaults and priorities.
|
||||
*/
|
||||
export class CloudService {
|
||||
constructor(name, options = {}) {
|
||||
this._name = name.toLowerCase();
|
||||
|
||||
// Allow the HTTP handler to be swapped out for tests
|
||||
const { _request = request } = options;
|
||||
|
||||
this._request = _request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search-friendly name of the Cloud Service.
|
||||
*
|
||||
* @return {String} Never {@code null}.
|
||||
*/
|
||||
getName() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using whatever mechanism is required by the current Cloud Service, determine
|
||||
* Kibana is running in it and return relevant metadata.
|
||||
*
|
||||
* @return {Promise} Never {@code null} {@code CloudServiceResponse}.
|
||||
*/
|
||||
checkIfService() {
|
||||
return this._checkIfService(this._request).catch(() => this._createUnconfirmedResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* Using whatever mechanism is required by the current Cloud Service, determine
|
||||
* Kibana is running in it and return relevant metadata.
|
||||
*
|
||||
* @param {Object} _request 'request' HTTP handler.
|
||||
* @return {Promise} Never {@code null} {@code CloudServiceResponse}.
|
||||
*/
|
||||
_checkIfService() {
|
||||
return Promise.reject(new Error('not implemented'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@code CloudServiceResponse} that denotes that this cloud service is not being used by the current machine / VM.
|
||||
*
|
||||
* @return {CloudServiceResponse} Never {@code null}.
|
||||
*/
|
||||
_createUnconfirmedResponse() {
|
||||
return CloudServiceResponse.unconfirmed(this._name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly parse JSON.
|
||||
*
|
||||
* @param {String} value The string to parse as a JSON object
|
||||
* @return {Object} The result of {@code JSON.parse} if it's an object.
|
||||
* @throws {Error} if the {@code value} is not a String that can be converted into an Object
|
||||
*/
|
||||
_stringToJson(value) {
|
||||
// note: this will throw an error if this is not a string
|
||||
value = value.trim();
|
||||
|
||||
// we don't want to return scalar values, arrays, etc.
|
||||
if (value.startsWith('{') && value.endsWith('}')) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
throw new Error(`'${value}' is not a JSON object`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the {@code response} to a JSON object and attempt to parse it using the {@code parseBody} function.
|
||||
*
|
||||
* If the {@code response} cannot be parsed as a JSON object, or if it fails to be useful, then {@code parseBody} should return
|
||||
* {@code null}.
|
||||
*
|
||||
* @param {Object} body The body from the response from the VM web service.
|
||||
* @param {Function} parseBody Single argument function that accepts parsed JSON body from the response.
|
||||
* @return {Promise} Never {@code null} {@code CloudServiceResponse} or rejection.
|
||||
*/
|
||||
_parseResponse(body, parseBody) {
|
||||
// parse it if necessary
|
||||
if (isString(body)) {
|
||||
try {
|
||||
body = this._stringToJson(body);
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(body)) {
|
||||
const response = parseBody(body);
|
||||
|
||||
if (response) {
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
// use default handling
|
||||
return Promise.reject();
|
||||
}
|
||||
}
|
|
@ -1,17 +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 { AWS } from './aws';
|
||||
import { AZURE } from './azure';
|
||||
import { GCP } from './gcp';
|
||||
|
||||
/**
|
||||
* An iteratable that can be used to loop across all known cloud services to detect them.
|
||||
*
|
||||
* @type {Array}
|
||||
*/
|
||||
export const CLOUD_SERVICES = [AWS, GCP, AZURE];
|
|
@ -1,22 +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 { CLOUD_SERVICES } from './cloud_services';
|
||||
import { AWS } from './aws';
|
||||
import { AZURE } from './azure';
|
||||
import { GCP } from './gcp';
|
||||
|
||||
describe('cloudServices', () => {
|
||||
const expectedOrder = [AWS, GCP, AZURE];
|
||||
|
||||
it('iterates in expected order', () => {
|
||||
let i = 0;
|
||||
for (const service of CLOUD_SERVICES) {
|
||||
expect(service).toBe(expectedOrder[i++]);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,136 +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 { isString } from 'lodash';
|
||||
import { promisify } from 'util';
|
||||
import { CloudService } from './cloud_service';
|
||||
import { CloudServiceResponse } from './cloud_response';
|
||||
import { CLOUD_METADATA_SERVICES } from '../../common/constants';
|
||||
|
||||
/**
|
||||
* {@code GCPCloudService} will check and load the service metadata for an Google Cloud Platform VM if it is available.
|
||||
*/
|
||||
class GCPCloudService extends CloudService {
|
||||
constructor(options = {}) {
|
||||
super('gcp', options);
|
||||
}
|
||||
|
||||
_checkIfService(request) {
|
||||
// we need to call GCP individually for each field
|
||||
const fields = ['id', 'machine-type', 'zone'];
|
||||
|
||||
const create = this._createRequestForField;
|
||||
const allRequests = fields.map((field) => promisify(request)(create(field)));
|
||||
return (
|
||||
Promise.all(allRequests)
|
||||
/*
|
||||
Note: there is no fallback option for GCP;
|
||||
responses are arrays containing [fullResponse, body];
|
||||
because GCP returns plaintext, we have no way of validating without using the response code
|
||||
*/
|
||||
.then((responses) => {
|
||||
return responses.map((response) => {
|
||||
return this._extractBody(response, response.body);
|
||||
});
|
||||
})
|
||||
.then(([id, machineType, zone]) => this._combineResponses(id, machineType, zone))
|
||||
);
|
||||
}
|
||||
|
||||
_createRequestForField(field) {
|
||||
return {
|
||||
method: 'GET',
|
||||
uri: `${CLOUD_METADATA_SERVICES.GCP_URL_PREFIX}/${field}`,
|
||||
headers: {
|
||||
// GCP requires this header
|
||||
'Metadata-Flavor': 'Google',
|
||||
},
|
||||
// GCP does _not_ return JSON
|
||||
json: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the body if the response is valid and it came from GCP.
|
||||
*
|
||||
* @param {Object} response The response object
|
||||
* @param {Object} body The response body, if any
|
||||
* @return {Object} {@code body} (probably actually a String) if the response came from GCP. Otherwise {@code null}.
|
||||
*/
|
||||
_extractBody(response, body) {
|
||||
if (
|
||||
response &&
|
||||
response.statusCode === 200 &&
|
||||
response.headers &&
|
||||
response.headers['metadata-flavor'] === 'Google'
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the GCP responses, if possible. Example values for each parameter:
|
||||
*
|
||||
* {@code vmId}: '5702733457649812345'
|
||||
* {@code machineType}: 'projects/441331612345/machineTypes/f1-micro'
|
||||
* {@code zone}: 'projects/441331612345/zones/us-east4-c'
|
||||
*
|
||||
* @param {String} vmId The ID of the VM
|
||||
* @param {String} machineType The machine type, prefixed by unwanted account info.
|
||||
* @param {String} zone The zone (e.g., availability zone), implicitly showing the region, prefixed by unwanted account info.
|
||||
* @return {CloudServiceResponse} Never {@code null}.
|
||||
* @throws {Error} if the responses do not make a valid response
|
||||
*/
|
||||
_combineResponses(id, machineType, zone) {
|
||||
const vmId = isString(id) ? id.trim() : null;
|
||||
const vmType = this._extractValue('machineTypes/', machineType);
|
||||
const vmZone = this._extractValue('zones/', zone);
|
||||
|
||||
let region;
|
||||
|
||||
if (vmZone) {
|
||||
// converts 'us-east4-c' into 'us-east4'
|
||||
region = vmZone.substring(0, vmZone.lastIndexOf('-'));
|
||||
}
|
||||
|
||||
// ensure we actually have some data
|
||||
if (vmId || vmType || region || vmZone) {
|
||||
return new CloudServiceResponse(this._name, true, { id: vmId, vmType, region, zone: vmZone });
|
||||
}
|
||||
|
||||
throw new Error('unrecognized responses');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the useful information returned from GCP while discarding unwanted account details (the project ID). For example,
|
||||
* this turns something like 'projects/441331612345/machineTypes/f1-micro' into 'f1-micro'.
|
||||
*
|
||||
* @param {String} fieldPrefix The value prefixing the actual value of interest.
|
||||
* @param {String} value The entire value returned from GCP.
|
||||
* @return {String} {@code undefined} if the value could not be extracted. Otherwise just the desired value.
|
||||
*/
|
||||
_extractValue(fieldPrefix, value) {
|
||||
if (isString(value)) {
|
||||
const index = value.lastIndexOf(fieldPrefix);
|
||||
|
||||
if (index !== -1) {
|
||||
return value.substring(index + fieldPrefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of {@code GCPCloudService}.
|
||||
*
|
||||
* @type {GCPCloudService}
|
||||
*/
|
||||
export const GCP = new GCPCloudService();
|
Loading…
Add table
Add a link
Reference in a new issue