[telemetry] Adds cloud provider metadata. (#95131)

This commit is contained in:
Luke Elmers 2021-04-12 10:55:44 -06:00 committed by GitHub
parent 5879d1fdf7
commit c9cd4a0a99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1348 additions and 1003 deletions

View file

@ -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`;

View file

@ -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),
}));

View file

@ -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);
});
});
});

View file

@ -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);
}

View file

@ -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);
});
});
});

View file

@ -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());
}
}

View file

@ -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);
});
});
});

View file

@ -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)
);
}
}

View file

@ -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 };

View file

@ -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();
});
});
});

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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`);
}

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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();
});
});

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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),
}));

View file

@ -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 }) => {

View file

@ -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);

View file

@ -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": {

View file

@ -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
*/

View file

@ -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();

View file

@ -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);
}
});
});
});

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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];

View file

@ -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++]);
}
});
});

View file

@ -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();