diff --git a/package.json b/package.json index 78121a60f0c98..9f14e2f678b80 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,6 @@ "**/pdfkit/crypto-js": "4.0.0", "**/react-syntax-highlighter": "^15.3.1", "**/react-syntax-highlighter/**/highlight.js": "^10.4.1", - "**/request": "^2.88.2", "**/trim": "1.0.1", "**/typescript": "4.1.3", "**/underscore": "^1.13.1" @@ -369,7 +368,6 @@ "regenerator-runtime": "^0.13.3", "remark-parse": "^8.0.3", "remark-stringify": "^9.0.0", - "request": "^2.88.0", "require-in-the-middle": "^5.0.2", "reselect": "^4.0.0", "resize-observer-polyfill": "^1.5.0", @@ -608,7 +606,6 @@ "@types/recompose": "^0.30.6", "@types/reduce-reducers": "^1.0.0", "@types/redux-actions": "^2.6.1", - "@types/request": "^2.48.2", "@types/seedrandom": ">=2.0.0 <4.0.0", "@types/selenium-webdriver": "^4.0.9", "@types/semver": "^7", diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts index 0bba64823a3e2..68583502d3c9a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.test.ts @@ -6,120 +6,120 @@ * Side Public License, v 1. */ -import fs from 'fs'; -import type { Request, RequestOptions } from './cloud_service'; +/* eslint-disable dot-notation */ +jest.mock('node-fetch'); +jest.mock('fs/promises'); import { AWSCloudService, AWSResponse } from './aws'; -type Callback = (err: unknown, res: unknown) => void; - -const AWS = new AWSCloudService(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fetchMock = require('node-fetch') as jest.Mock; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { readFile } = require('fs/promises') as { readFile: jest.Mock }; 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; + const mockIsWindows = jest.fn(); + const awsService = new AWSCloudService(); + awsService['_isWindows'] = mockIsWindows.mockReturnValue(false); + readFile.mockResolvedValue('eC2abcdef-ghijk\n'); + beforeEach(() => jest.clearAllMocks()); it('is named "aws"', () => { - expect(AWS.getName()).toEqual('aws'); + expect(awsService.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, + + fetchMock.mockResolvedValue({ + json: () => + `{"instanceId": "${id}","availabilityZone":"us-fake-2c", "imageId" : "ami-6df1e514"}`, + status: 200, + ok: true, }); - const response = await awsCheckedFileSystem._checkIfService(request); + const response = await awsService['_checkIfService'](); + expect(readFile).toBeCalledTimes(0); + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock).toBeCalledWith( + 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document', + { + method: 'GET', + } + ); 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', - }, - }); + expect(response.toJSON()).toMatchInlineSnapshot(` + Object { + "id": "abcdef", + "metadata": Object { + "imageId": "ami-6df1e514", + }, + "name": "aws", + "region": undefined, + "vm_type": undefined, + "zone": "us-fake-2c", + } + `); }); 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, + fetchMock.mockResolvedValue({ + json: () => null, + status: 200, + ok: true, }); - const response = await awsCheckedFileSystem._checkIfService(request); + const response = await awsService['_checkIfService'](); 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, - }); + expect(response.toJSON()).toMatchInlineSnapshot(` + Object { + "id": "ec2abcdef-ghijk", + "metadata": undefined, + "name": "aws", + "region": undefined, + "vm_type": undefined, + "zone": 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, + fetchMock.mockResolvedValue({ + status: 404, + ok: false, }); - const response = await awsCheckedFileSystem._checkIfService(failedRequest); + const response = await awsService['_checkIfService'](); 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, - }); + expect(response.toJSON()).toMatchInlineSnapshot(` + Object { + "id": "ec2abcdef-ghijk", + "metadata": undefined, + "name": "aws", + "region": undefined, + "vm_type": undefined, + "zone": 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, + fetchMock.mockResolvedValue({ + json: () => null, + status: 404, + ok: false, }); - const response = await awsIgnoredFileSystem._checkIfService(failedRequest); + mockIsWindows.mockReturnValue(true); + + const response = await awsService['_checkIfService'](); + expect(mockIsWindows).toBeCalledTimes(1); + expect(readFile).toBeCalledTimes(0); - expect(response.getName()).toEqual(AWS.getName()); + expect(response.getName()).toEqual('aws'); expect(response.isConfirmed()).toBe(false); }); }); @@ -144,10 +144,10 @@ describe('AWS', () => { marketplaceProductCodes: null, }; - const response = AWSCloudService.parseBody(AWS.getName(), body)!; + const response = awsService.parseBody(body)!; expect(response).not.toBeNull(); - expect(response.getName()).toEqual(AWS.getName()); + expect(response.getName()).toEqual('aws'); expect(response.isConfirmed()).toEqual(true); expect(response.toJSON()).toEqual({ name: 'aws', @@ -169,141 +169,84 @@ describe('AWS', () => { it('ignores unexpected response body', () => { // @ts-expect-error - expect(AWSCloudService.parseBody(AWS.getName(), undefined)).toBe(null); + expect(awsService.parseBody(undefined)).toBe(null); // @ts-expect-error - expect(AWSCloudService.parseBody(AWS.getName(), null)).toBe(null); + expect(awsService.parseBody(null)).toBe(null); // @ts-expect-error - expect(AWSCloudService.parseBody(AWS.getName(), {})).toBe(null); + expect(awsService.parseBody({})).toBe(null); // @ts-expect-error - expect(AWSCloudService.parseBody(AWS.getName(), { privateIp: 'a.b.c.d' })).toBe(null); + expect(awsService.parseBody({ privateIp: 'a.b.c.d' })).toBe(null); }); }); - describe('_tryToDetectUuid', () => { + 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, - }); + beforeAll(() => mockIsWindows.mockReturnValue(false)); - const response = await awsCheckedFileSystem._tryToDetectUuid(); + it('checks /sys/hypervisor/uuid and /sys/devices/virtual/dmi/id/product_uuid', async () => { + const response = await awsService['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, - }); - }); + readFile.mockImplementation(async (filename: string, encoding: string) => { + expect(['/sys/hypervisor/uuid', '/sys/devices/virtual/dmi/id/product_uuid']).toContain( + filename + ); + expect(encoding).toEqual('utf8'); - 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, + return 'eC2abcdef-ghijk\n'; }); - const response = await awsCheckedFileSystem._tryToDetectUuid(); - + expect(readFile).toBeCalledTimes(2); 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, - }); + expect(response.toJSON()).toMatchInlineSnapshot(` + Object { + "id": "ec2abcdef-ghijk", + "metadata": undefined, + "name": "aws", + "region": undefined, + "vm_type": undefined, + "zone": 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, - }); + readFile.mockRejectedValueOnce(new Error('oops')); + readFile.mockResolvedValueOnce('ec2Uuid'); - const response = await awsCheckedFileSystem._tryToDetectUuid(); + const response = await awsService['tryToDetectUuid'](); + expect(readFile).toBeCalledTimes(2); 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, - }); + expect(response.toJSON()).toMatchInlineSnapshot(` + Object { + "id": "ec2uuid", + "metadata": undefined, + "name": "aws", + "region": undefined, + "vm_type": undefined, + "zone": 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(); + readFile.mockRejectedValue(new Error('oops')); + const response = await awsService['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); + it('ignores UUID if it does not start with ec2', async () => { + readFile.mockResolvedValue('notEC2'); - callback(null, 'notEC2'); - }, - } as typeof fs; - - const awsCheckedFileSystem = new AWSCloudService({ - _fs: notEC2FileSystem, - _isWindows: false, + const response = await awsService['tryToDetectUuid'](); + expect(response.isConfirmed()).toEqual(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(); + mockIsWindows.mockReturnValue(true); + const response = await awsService['tryToDetectUuid'](); expect(response.isConfirmed()).toEqual(false); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts index 69e5698489b30..785313e752c5e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/aws.ts @@ -6,10 +6,10 @@ * 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 { readFile } from 'fs/promises'; +import { get, omit } from 'lodash'; +import fetch from 'node-fetch'; +import { CloudService } from './cloud_service'; import { CloudServiceResponse } from './cloud_response'; // We explicitly call out the version, 2016-09-02, rather than 'latest' to avoid unexpected changes @@ -40,9 +40,9 @@ export interface AWSResponse { * @internal */ export class AWSCloudService extends CloudService { - private readonly _isWindows: boolean; - private readonly _fs: typeof fs; - + constructor() { + super('aws'); + } /** * Parse the AWS response, if possible. * @@ -64,7 +64,8 @@ export class AWSCloudService extends CloudService { * "version" : "2010-08-31", * } */ - static parseBody(name: string, body: AWSResponse): CloudServiceResponse | null { + parseBody = (body: AWSResponse): CloudServiceResponse | null => { + const name = this.getName(); const id: string | undefined = get(body, 'instanceId'); const vmType: string | undefined = get(body, 'instanceType'); const region: string | undefined = get(body, 'region'); @@ -88,64 +89,60 @@ export class AWSCloudService extends CloudService { } return null; - } + }; - constructor(options: CloudServiceOptions = {}) { - super('aws', options); + private _isWindows = (): boolean => { + return process.platform.startsWith('win'); + }; - // 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; - } + protected _checkIfService = async () => { + try { + const response = await fetch(SERVICE_ENDPOINT, { + method: 'GET', + }); - async _checkIfService(request: Request) { - const req: RequestOptions = { - method: 'GET', - uri: SERVICE_ENDPOINT, - json: true, - }; + if (!response.ok || response.status === 404) { + throw new Error('AWS request failed'); + } - return promisify(request)(req) - .then((response) => - this._parseResponse(response.body, (body) => - AWSCloudService.parseBody(this.getName(), body) - ) - ) - .catch(() => this._tryToDetectUuid()); - } + const jsonBody: AWSResponse = await response.json(); + return this._parseResponse(jsonBody, this.parseBody); + } catch (_) { + return 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() { + private tryToDetectUuid = async () => { + const isWindows = this._isWindows(); // Windows does not have an easy way to check - if (!this._isWindows) { + if (!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 }); - } + const responses = await Promise.allSettled( + pathsToCheck.map((path) => readFile(path, 'utf8')) + ); + + for (const response of responses) { + let uuid; + if (response.status === 'fulfilled' && typeof response.value === 'string') { + // 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 this._createUnconfirmedResponse(); } - return Promise.resolve(this._createUnconfirmedResponse()); - } + return this._createUnconfirmedResponse(); + }; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts index 17205562fa335..5bdbbbda55de6 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.test.ts @@ -6,36 +6,47 @@ * Side Public License, v 1. */ -import type { Request, RequestOptions } from './cloud_service'; +/* eslint-disable dot-notation */ +jest.mock('node-fetch'); import { AzureCloudService } from './azure'; -type Callback = (err: unknown, res: unknown) => void; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fetchMock = require('node-fetch') as jest.Mock; -const AZURE = new AzureCloudService(); - -describe('Azure', () => { +describe('AzureCloudService', () => { + const azureCloudService = new AzureCloudService(); it('is named "azure"', () => { - expect(AZURE.getName()).toEqual('azure'); + expect(azureCloudService.getName()).toEqual('azure'); }); describe('_checkIfService', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + 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/metadata/instance?api-version=2017-04-02'); - expect(req.headers?.Metadata).toEqual('true'); - expect(req.json).toEqual(true); + fetchMock.mockResolvedValue({ + json: () => + `{"compute":{"vmId": "${id}","location":"fakeus","availabilityZone":"fakeus-2"}}`, + status: 200, + ok: true, + }); - const body = `{"compute":{"vmId": "${id}","location":"fakeus","availabilityZone":"fakeus-2"}}`; + const response = await azureCloudService['_checkIfService'](); - callback(null, { statusCode: 200, body }); - }) as Request; - const response = await AZURE._checkIfService(request); + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock).toBeCalledWith( + 'http://169.254.169.254/metadata/instance?api-version=2017-04-02', + { + method: 'GET', + headers: { Metadata: 'true' }, + } + ); expect(response.isConfirmed()).toEqual(true); expect(response.toJSON()).toEqual({ - name: AZURE.getName(), + name: azureCloudService.getName(), id, region: 'fakeus', vm_type: undefined, @@ -49,34 +60,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: RequestOptions, callback: Callback) => - callback(someError, null)) as Request; + fetchMock.mockRejectedValue(someError); - expect(async () => { - await AZURE._checkIfService(failedRequest); - }).rejects.toThrowError(someError.message); + await expect(() => azureCloudService['_checkIfService']()).rejects.toThrowError( + someError.message + ); }); it('handles not running on Azure with 404 response by throwing error', async () => { - const failedRequest = ((_req: RequestOptions, callback: Callback) => - callback(null, { statusCode: 404 })) as Request; + fetchMock.mockResolvedValue({ status: 404 }); - expect(async () => { - await AZURE._checkIfService(failedRequest); - }).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`); + await expect(() => + azureCloudService['_checkIfService']() + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`); }); it('handles not running on Azure with unexpected response by throwing error', async () => { - const failedRequest = ((_req: RequestOptions, callback: Callback) => - callback(null, null)) as Request; - - expect(async () => { - await AZURE._checkIfService(failedRequest); - }).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`); + fetchMock.mockResolvedValue({ ok: false }); + await expect(() => + azureCloudService['_checkIfService']() + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Azure request failed"`); }); }); - describe('_parseBody', () => { + describe('parseBody', () => { // it's expected that most users use the resource manager UI (which has been out for years) it('parses object in expected format', () => { const body = { @@ -119,10 +126,10 @@ describe('Azure', () => { }, }; - const response = AzureCloudService.parseBody(AZURE.getName(), body)!; + const response = azureCloudService['parseBody'](body)!; expect(response).not.toBeNull(); - expect(response.getName()).toEqual(AZURE.getName()); + expect(response.getName()).toEqual(azureCloudService.getName()); expect(response.isConfirmed()).toEqual(true); expect(response.toJSON()).toEqual({ name: 'azure', @@ -172,10 +179,10 @@ describe('Azure', () => { }, }; - const response = AzureCloudService.parseBody(AZURE.getName(), body)!; + const response = azureCloudService['parseBody'](body)!; expect(response).not.toBeNull(); - expect(response.getName()).toEqual(AZURE.getName()); + expect(response.getName()).toEqual(azureCloudService.getName()); expect(response.isConfirmed()).toEqual(true); expect(response.toJSON()).toEqual({ name: 'azure', @@ -191,13 +198,13 @@ describe('Azure', () => { it('ignores unexpected response body', () => { // @ts-expect-error - expect(AzureCloudService.parseBody(AZURE.getName(), undefined)).toBe(null); + expect(azureCloudService['parseBody'](undefined)).toBe(null); // @ts-expect-error - expect(AzureCloudService.parseBody(AZURE.getName(), null)).toBe(null); + expect(azureCloudService['parseBody'](null)).toBe(null); // @ts-expect-error - expect(AzureCloudService.parseBody(AZURE.getName(), {})).toBe(null); + expect(azureCloudService['parseBody']({})).toBe(null); // @ts-expect-error - expect(AzureCloudService.parseBody(AZURE.getName(), { privateIp: 'a.b.c.d' })).toBe(null); + expect(azureCloudService['parseBody']({ privateIp: 'a.b.c.d' })).toBe(null); }); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts index b846636f0ce6c..06a135960bd60 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/azure.ts @@ -7,8 +7,8 @@ */ import { get, omit } from 'lodash'; -import { promisify } from 'util'; -import { CloudService, Request } from './cloud_service'; +import fetch from 'node-fetch'; +import { CloudService } from './cloud_service'; import { CloudServiceResponse } from './cloud_response'; // 2017-04-02 is the first GA release of this API @@ -25,6 +25,9 @@ interface AzureResponse { * @internal */ export class AzureCloudService extends CloudService { + constructor() { + super('azure'); + } /** * Parse the Azure response, if possible. * @@ -51,7 +54,8 @@ export class AzureCloudService extends CloudService { * } * } */ - static parseBody(name: string, body: AzureResponse): CloudServiceResponse | null { + private parseBody = (body: AzureResponse): CloudServiceResponse | null => { + const name = this.getName(); const compute: Record | undefined = get(body, 'compute'); const id = get, string>(compute, 'vmId'); const vmType = get, string>(compute, 'vmSize'); @@ -72,32 +76,22 @@ export class AzureCloudService extends CloudService { } return null; - } - - constructor(options = {}) { - super('azure', options); - } + }; - async _checkIfService(request: Request) { - const req = { + protected _checkIfService = async () => { + const response = await fetch(SERVICE_ENDPOINT, { 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) { + if (!response.ok || response.status === 404) { throw new Error('Azure request failed'); } - return this._parseResponse(response.body, (body) => - AzureCloudService.parseBody(this.getName(), body) - ); - } + const jsonBody: AzureResponse = await response.json(); + return this._parseResponse(jsonBody, this.parseBody); + }; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts index 3d093c81f8896..9930110979b87 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_detector.ts @@ -37,9 +37,9 @@ export class CloudDetector { /** * Get any cloud details that we have detected. */ - getCloudDetails() { + public getCloudDetails = () => { return this.cloudDetails; - } + }; /** * Asynchronously detect the cloud service. @@ -48,9 +48,9 @@ export class CloudDetector { * caller to trigger the lookup and then simply use it whenever we * determine it. */ - async detectCloudService() { + public detectCloudService = async () => { this.cloudDetails = await this.getCloudService(); - } + }; /** * Check every cloud service until the first one reports success from detection. diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts index 0a7d5899486ab..22bef6753e9cf 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { CloudService, Response } from './cloud_service'; +import { CloudService } from './cloud_service'; import { CloudServiceResponse } from './cloud_response'; describe('CloudService', () => { @@ -30,9 +30,9 @@ describe('CloudService', () => { describe('_checkIfService', () => { it('throws an exception unless overridden', async () => { - expect(async () => { - await service._checkIfService(undefined); - }).rejects.toThrowErrorMatchingInlineSnapshot(`"not implemented"`); + await expect(() => + service._checkIfService(undefined) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"not implemented"`); }); }); @@ -88,52 +88,59 @@ describe('CloudService', () => { describe('_parseResponse', () => { const body = { some: { body: {} } }; - it('throws error upon failure to parse body as object', async () => { - 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('throws error upon failure to parse body as object', () => { + expect(() => service._parseResponse()).toThrowErrorMatchingInlineSnapshot( + `"Unable to handle body"` + ); + expect(() => service._parseResponse(null)).toThrowErrorMatchingInlineSnapshot( + `"Unable to handle body"` + ); + expect(() => service._parseResponse({})).toThrowErrorMatchingInlineSnapshot( + `"Unable to handle body"` + ); + expect(() => service._parseResponse(123)).toThrowErrorMatchingInlineSnapshot( + `"Unable to handle body"` + ); + expect(() => service._parseResponse('raw string')).toThrowErrorMatchingInlineSnapshot( + `"'raw string' is not a JSON object"` + ); + expect(() => service._parseResponse('{{}')).toThrowErrorMatchingInlineSnapshot( + `"'{{}' is not a JSON object"` + ); }); - it('expects unusable bodies', async () => { - const parseBody = (parsedBody: Response['body']) => { - expect(parsedBody).toEqual(body); - - return null; - }; - - expect(async () => { - await service._parseResponse(JSON.stringify(body), parseBody); - }).rejects.toMatchInlineSnapshot(`undefined`); - expect(async () => { - await service._parseResponse(body, parseBody); - }).rejects.toMatchInlineSnapshot(`undefined`); + it('expects unusable bodies', () => { + const parseBody = jest.fn().mockReturnValue(null); + + expect(() => + service._parseResponse(JSON.stringify(body), parseBody) + ).toThrowErrorMatchingInlineSnapshot(`"Unable to handle body"`); + expect(parseBody).toBeCalledTimes(1); + expect(parseBody).toBeCalledWith(body); + parseBody.mockClear(); + + expect(() => service._parseResponse(body, parseBody)).toThrowErrorMatchingInlineSnapshot( + `"Unable to handle body"` + ); + expect(parseBody).toBeCalledTimes(1); + expect(parseBody).toBeCalledWith(body); }); it('uses parsed object to create response', async () => { const serviceResponse = new CloudServiceResponse('a123', true, { id: 'xyz' }); - const parseBody = (parsedBody: Response['body']) => { - expect(parsedBody).toEqual(body); - - return serviceResponse; - }; + const parseBody = jest.fn().mockReturnValue(serviceResponse); const response = await service._parseResponse(body, parseBody); + expect(parseBody).toBeCalledWith(body); + expect(response).toBe(serviceResponse); + }); + + it('parses object before passing it to parseBody to create response', async () => { + const serviceResponse = new CloudServiceResponse('a123', true, { id: 'xyz' }); + const parseBody = jest.fn().mockReturnValue(serviceResponse); + const response = await service._parseResponse(JSON.stringify(body), parseBody); + expect(parseBody).toBeCalledWith(body); expect(response).toBe(serviceResponse); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts index 768a46a457d7d..bea51437d25c4 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/cloud_service.ts @@ -6,81 +6,56 @@ * 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 { isObject, isPlainObject } from 'lodash'; 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 = {}) { + constructor(name: string) { 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() { + public 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() { + public checkIfService = async () => { try { - return await this._checkIfService(this._request); + return await this._checkIfService(); } catch (e) { return this._createUnconfirmedResponse(); } - } + }; - _checkIfService(request: Request): Promise { + protected _checkIfService = async (): Promise => { // 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() { + protected _createUnconfirmedResponse = () => { return CloudServiceResponse.unconfirmed(this._name); - } + }; /** * Strictly parse JSON. */ - _stringToJson(value: string) { + protected _stringToJson = (value: string) => { // note: this will throw an error if this is not a string value = value.trim(); @@ -94,7 +69,7 @@ export abstract class CloudService { } 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 @@ -103,28 +78,21 @@ export abstract class CloudService { * 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 { + protected _parseResponse = ( + body: string | Body, + parseBodyFn: (body: Body) => CloudServiceResponse | null + ): 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); + const jsonBody: Body = typeof body === 'string' ? this._stringToJson(body) : body; + if (isObject(jsonBody) && typeof parseBodyFn !== 'undefined') { + const response = parseBodyFn(jsonBody); if (response) { - return Promise.resolve(response); + return response; } } // use default handling - return Promise.reject(); - } + throw new Error('Unable to handle body'); + }; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts index fd0b3331b4ad1..40bd0ef1fa1b1 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.test.ts @@ -5,136 +5,185 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import type { Request, RequestOptions } from './cloud_service'; +/* eslint-disable dot-notation */ +jest.mock('node-fetch'); import { GCPCloudService } from './gcp'; - -type Callback = (err: unknown, res: unknown) => void; - -const GCP = new GCPCloudService(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fetchMock = require('node-fetch') as jest.Mock; describe('GCP', () => { + const gcpService = new GCPCloudService(); + beforeEach(() => jest.clearAllMocks()); + it('is named "gcp"', () => { - expect(GCP.getName()).toEqual('gcp'); + expect(gcpService.getName()).toEqual('gcp'); }); describe('_checkIfService', () => { // GCP responds with the header that they expect (and request lowercases the header's name) - const headers = { 'metadata-flavor': 'Google' }; + const headers = new Map(); + headers.set('metadata-flavor', 'Google'); it('handles expected responses', async () => { + const basePath = 'http://169.254.169.254/computeMetadata/v1/instance/'; const metadata: Record = { id: 'abcdef', 'machine-type': 'projects/441331612345/machineTypes/f1-micro', zone: 'projects/441331612345/zones/us-fake4-c', }; - const request = ((req: RequestOptions, callback: Callback) => { - const basePath = 'http://169.254.169.254/computeMetadata/v1/instance/'; - - expect(req.method).toEqual('GET'); - 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 as string).substring(basePath.length); - let body = null; + fetchMock.mockImplementation((url: string) => { + const requestKey = url.substring(basePath.length); + let body: string | null = null; if (metadata[requestKey]) { body = metadata[requestKey]; } + return { + status: 200, + ok: true, + text: () => body, + headers, + }; + }); - callback(null, { statusCode: 200, body, headers }); - }) as Request; - const response = await GCP._checkIfService(request); + const response = await gcpService['_checkIfService'](); + const fetchParams = { + headers: { 'Metadata-Flavor': 'Google' }, + method: 'GET', + }; + expect(fetchMock).toBeCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith(1, `${basePath}id`, fetchParams); + expect(fetchMock).toHaveBeenNthCalledWith(2, `${basePath}machine-type`, fetchParams); + expect(fetchMock).toHaveBeenNthCalledWith(3, `${basePath}zone`, fetchParams); expect(response.isConfirmed()).toEqual(true); - expect(response.toJSON()).toEqual({ - name: GCP.getName(), - id: metadata.id, - region: 'us-fake4', - vm_type: 'f1-micro', - zone: 'us-fake4-c', - metadata: undefined, - }); + expect(response.toJSON()).toMatchInlineSnapshot(` + Object { + "id": "abcdef", + "metadata": undefined, + "name": "gcp", + "region": "us-fake4", + "vm_type": "f1-micro", + "zone": "us-fake4-c", + } + `); }); // NOTE: the CloudService method, checkIfService, catches the errors that follow it('handles unexpected responses', async () => { - const request = ((_req: RequestOptions, callback: Callback) => - callback(null, { statusCode: 200, headers })) as Request; + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers, + text: () => undefined, + }); - expect(async () => { - await GCP._checkIfService(request); - }).rejects.toThrowErrorMatchingInlineSnapshot(`"unrecognized responses"`); + await expect(() => + gcpService['_checkIfService']() + ).rejects.toThrowErrorMatchingInlineSnapshot(`"unrecognized responses"`); }); it('handles unexpected responses without response header', async () => { - const body = 'xyz'; - const failedRequest = ((_req: RequestOptions, callback: Callback) => - callback(null, { statusCode: 200, body })) as Request; + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Map(), + text: () => 'xyz', + }); - expect(async () => { - await GCP._checkIfService(failedRequest); - }).rejects.toThrowErrorMatchingInlineSnapshot(`"unrecognized responses"`); + await expect(() => + gcpService['_checkIfService']() + ).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`); }); - it('handles not running on GCP with error by rethrowing it', async () => { + it('handles not running on GCP', async () => { const someError = new Error('expected: request failed'); - const failedRequest = ((_req: RequestOptions, callback: Callback) => - callback(someError, null)) as Request; + fetchMock.mockRejectedValue(someError); - expect(async () => { - await GCP._checkIfService(failedRequest); - }).rejects.toThrowError(someError); + await expect(() => + gcpService['_checkIfService']() + ).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`); }); it('handles not running on GCP with 404 response by throwing error', async () => { - const body = 'This is some random error text'; - const failedRequest = ((_req: RequestOptions, callback: Callback) => - callback(null, { statusCode: 404, headers, body })) as Request; + fetchMock.mockResolvedValue({ + status: 404, + ok: false, + headers, + text: () => 'This is some random error text', + }); - expect(async () => { - await GCP._checkIfService(failedRequest); - }).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`); + await expect(() => + gcpService['_checkIfService']() + ).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`); }); - it('handles not running on GCP with unexpected response by throwing error', async () => { - const failedRequest = ((_req: RequestOptions, callback: Callback) => - callback(null, null)) as Request; + it('handles GCP response even if some requests fail', async () => { + fetchMock + .mockResolvedValueOnce({ + status: 200, + ok: true, + headers, + text: () => 'some_id', + }) + .mockRejectedValueOnce({ + status: 500, + ok: false, + headers, + text: () => 'This is some random error text', + }) + .mockResolvedValueOnce({ + status: 404, + ok: false, + headers, + text: () => 'URI Not found', + }); + const response = await gcpService['_checkIfService'](); + + expect(fetchMock).toBeCalledTimes(3); - expect(async () => { - await GCP._checkIfService(failedRequest); - }).rejects.toThrowErrorMatchingInlineSnapshot(`"GCP request failed"`); + expect(response.isConfirmed()).toEqual(true); + expect(response.toJSON()).toMatchInlineSnapshot(` + Object { + "id": "some_id", + "metadata": undefined, + "name": "gcp", + "region": undefined, + "vm_type": undefined, + "zone": undefined, + } + `); }); }); - describe('_extractValue', () => { + describe('extractValue', () => { it('only handles strings', () => { // @ts-expect-error - expect(GCP._extractValue()).toBe(undefined); + expect(gcpService['extractValue']()).toBe(undefined); // @ts-expect-error - expect(GCP._extractValue(null, null)).toBe(undefined); + expect(gcpService['extractValue'](null, null)).toBe(undefined); // @ts-expect-error - expect(GCP._extractValue('abc', { field: 'abcxyz' })).toBe(undefined); + expect(gcpService['extractValue']('abc', { field: 'abcxyz' })).toBe(undefined); // @ts-expect-error - expect(GCP._extractValue('abc', 1234)).toBe(undefined); - expect(GCP._extractValue('abc/', 'abc/xyz')).toEqual('xyz'); + expect(gcpService['extractValue']('abc', 1234)).toBe(undefined); + expect(gcpService['extractValue']('abc/', 'abc/xyz')).toEqual('xyz'); }); it('uses the last index of the prefix to truncate', () => { - expect(GCP._extractValue('abc/', ' \n 123/abc/xyz\t \n')).toEqual('xyz'); + expect(gcpService['extractValue']('abc/', ' \n 123/abc/xyz\t \n')).toEqual('xyz'); }); }); - describe('_combineResponses', () => { + describe('combineResponses', () => { it('parses in expected format', () => { const id = '5702733457649812345'; const machineType = 'projects/441331612345/machineTypes/f1-micro'; const zone = 'projects/441331612345/zones/us-fake4-c'; - const response = GCP._combineResponses(id, machineType, zone); + const response = gcpService['combineResponses'](id, machineType, zone); - expect(response.getName()).toEqual(GCP.getName()); + expect(response.getName()).toEqual('gcp'); expect(response.isConfirmed()).toEqual(true); expect(response.toJSON()).toEqual({ name: 'gcp', @@ -152,9 +201,9 @@ describe('GCP', () => { const machineType = 'f1-micro'; const zone = 'us-fake4-c'; - const response = GCP._combineResponses(id, machineType, zone); + const response = gcpService['combineResponses'](id, machineType, zone); - expect(response.getName()).toEqual(GCP.getName()); + expect(response.getName()).toEqual('gcp'); expect(response.isConfirmed()).toEqual(true); expect(response.toJSON()).toEqual({ name: 'gcp', @@ -167,18 +216,16 @@ describe('GCP', () => { }); it('ignores unexpected response body', () => { + expect(() => gcpService['combineResponses']()).toThrow(); + expect(() => gcpService['combineResponses'](undefined, undefined, undefined)).toThrow(); // @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(() => gcpService['combineResponses'](null, null, null)).toThrow(); expect(() => // @ts-expect-error - GCP._combineResponses({ id: 'x' }, { machineType: 'a' }, { zone: 'b' }) + gcpService['combineResponses']({ id: 'x' }, { machineType: 'a' }, { zone: 'b' }) ).toThrow(); // @ts-expect-error - expect(() => GCP._combineResponses({ privateIp: 'a.b.c.d' })).toThrow(); + expect(() => gcpService['combineResponses']({ privateIp: 'a.b.c.d' })).toThrow(); }); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts index 565c07abd1d2c..2cdf3f87cfe8f 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/detector/gcp.ts @@ -6,14 +6,15 @@ * Side Public License, v 1. */ -import { isString } from 'lodash'; -import { promisify } from 'util'; -import { CloudService, CloudServiceOptions, Request, Response } from './cloud_service'; +import fetch, { Response } from 'node-fetch'; +import { CloudService } 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'; +// GCP required headers +const SERVICE_HEADERS = { 'Metadata-Flavor': 'Google' }; /** * Checks and loads the service metadata for an Google Cloud Platform VM if it is available. @@ -21,61 +22,54 @@ const SERVICE_ENDPOINT = 'http://169.254.169.254/computeMetadata/v1/instance'; * @internal */ export class GCPCloudService extends CloudService { - constructor(options: CloudServiceOptions = {}) { - super('gcp', options); + constructor() { + super('gcp'); } - _checkIfService(request: Request) { + protected _checkIfService = async () => { // 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)) + const settledResponses = await Promise.allSettled( + fields.map(async (field) => { + return await fetch(`${SERVICE_ENDPOINT}/${field}`, { + method: 'GET', + headers: { ...SERVICE_HEADERS }, + }); + }) ); - } - _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, - }; - } + const hasValidResponses = settledResponses.some(this.isValidResponse); - /** - * 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; + if (!hasValidResponses) { + throw new Error('GCP request failed'); } - return null; - } + // 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. + const [id, machineType, zone] = await Promise.all( + settledResponses.map(async (settledResponse) => { + if (this.isValidResponse(settledResponse)) { + // GCP does _not_ return JSON + return await settledResponse.value.text(); + } + }) + ); + + return this.combineResponses(id, machineType, zone); + }; + + private isValidResponse = ( + settledResponse: PromiseSettledResult + ): settledResponse is PromiseFulfilledResult => { + if (settledResponse.status === 'rejected') { + return false; + } + const { value } = settledResponse; + return value.ok && value.status !== 404 && value.headers.get('metadata-flavor') === 'Google'; + }; /** * Parse the GCP responses, if possible. @@ -86,17 +80,11 @@ export class GCPCloudService extends CloudService { * 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('-')); - } + private combineResponses = (id?: string, machineType?: string, zone?: string) => { + const vmId = typeof id === 'string' ? id.trim() : undefined; + const vmType = this.extractValue('machineTypes/', machineType); + const vmZone = this.extractValue('zones/', zone); + const region = vmZone ? vmZone.substring(0, vmZone.lastIndexOf('-')) : undefined; // ensure we actually have some data if (vmId || vmType || region || vmZone) { @@ -104,7 +92,7 @@ export class GCPCloudService extends CloudService { } throw new Error('unrecognized responses'); - } + }; /** * Extract the useful information returned from GCP while discarding @@ -113,15 +101,15 @@ export class GCPCloudService extends CloudService { * 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(); - } + private extractValue = (fieldPrefix: string, value?: string) => { + if (typeof value !== 'string') { + return; } - return undefined; - } + const index = value.lastIndexOf(fieldPrefix); + + if (index !== -1) { + return value.substring(index + fieldPrefix.length).trim(); + } + }; } diff --git a/yarn.lock b/yarn.lock index 82b93c9d2890c..b084fa8162df8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4913,11 +4913,6 @@ "@types/node" "*" "@types/responselike" "*" -"@types/caseless@*": - version "0.12.2" - resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" - integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== - "@types/chance@^1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.0.1.tgz#c10703020369602c40dd9428cc6e1437027116df" @@ -6024,16 +6019,6 @@ dependencies: "@types/prismjs" "*" -"@types/request@^2.48.2": - version "2.48.2" - resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.2.tgz#936374cbe1179d7ed529fc02543deb4597450fed" - integrity sha512-gP+PSFXAXMrd5PcD7SqHeUjdGshAI8vKQ3+AvpQr3ht9iQea+59LOKvKITcQI+Lg+1EIkDP6AFSBUJPWG8GDyA== - dependencies: - "@types/caseless" "*" - "@types/node" "*" - "@types/tough-cookie" "*" - form-data "^2.5.0" - "@types/resize-observer-browser@^0.1.5": version "0.1.5" resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23" @@ -6921,6 +6906,14 @@ ajv-keywords@^3.5.2: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== +ajv@^4.9.1: + version "4.11.8" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536" + integrity sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY= + dependencies: + co "^4.6.0" + json-stable-stringify "^1.0.1" + ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.5.5, ajv@^6.9.1: version "6.12.4" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" @@ -7505,6 +7498,11 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= +assert-plus@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" + integrity sha1-104bh+ev/A24qttwIfP+SBAasjQ= + assert@^1.1.1: version "1.4.1" resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" @@ -7735,11 +7733,21 @@ available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2: dependencies: array-filter "^1.0.0" +aws-sign2@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" + integrity sha1-FDQt0428yU0OW4fXY81jYSwOeU8= + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= +aws4@^1.2.1: + version "1.11.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + aws4@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" @@ -8495,6 +8503,13 @@ boolbase@^1.0.0, boolbase@~1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= +boom@2.x.x: + version "2.10.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" + integrity sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8= + dependencies: + hoek "2.x.x" + bottleneck@^2.15.3: version "2.18.0" resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.18.0.tgz#41fa63ae185b65435d789d1700334bc48222dacf" @@ -9897,9 +9912,9 @@ combine-source-map@^0.8.0, combine-source-map@~0.8.0: lodash.memoize "~3.0.3" source-map "~0.5.3" -combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: +combined-stream@^1.0.5, combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.5, combined-stream@~1.0.6: version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== dependencies: delayed-stream "~1.0.0" @@ -10474,6 +10489,13 @@ crypt@~0.0.1: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= +cryptiles@2.x.x: + version "2.0.5" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" + integrity sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g= + dependencies: + boom "2.x.x" + crypto-browserify@^3.0.0, crypto-browserify@^3.11.0: version "3.12.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" @@ -13422,7 +13444,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@^3.0.0, extend@~3.0.2: +extend@^3.0.0, extend@~3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -14088,7 +14110,7 @@ fork-ts-checker-webpack-plugin@4.1.6, fork-ts-checker-webpack-plugin@^4.1.4: tapable "^1.0.0" worker-rpc "^0.1.0" -form-data@^2.3.1, form-data@^2.5.0: +form-data@^2.3.1: version "2.5.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.0.tgz#094ec359dc4b55e7d62e0db4acd76e89fe874d37" integrity sha512-WXieX3G/8side6VIqx44ablyULoGruSde5PNTxoUyo5CeyAMX6nVWUd0rgist/EuX655cjhUhTo1Fo3tRYqbcA== @@ -14115,6 +14137,15 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@~2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1" + integrity sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE= + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.5" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -15139,11 +15170,24 @@ handlebars@4.7.7, handlebars@^4.7.7: optionalDependencies: uglify-js "^3.1.4" +har-schema@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e" + integrity sha1-0mMTX0MwfALGAq/I/pWXDAFRNp4= + har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= +har-validator@~4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a" + integrity sha1-M0gdDxu/9gDdID11gSpqX7oALio= + dependencies: + ajv "^4.9.1" + har-schema "^1.0.5" + har-validator@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" @@ -15403,6 +15447,16 @@ hat@0.0.3: resolved "https://registry.yarnpkg.com/hat/-/hat-0.0.3.tgz#bb014a9e64b3788aed8005917413d4ff3d502d8a" integrity sha1-uwFKnmSzeIrtgAWRdBPU/z1QLYo= +hawk@~3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" + integrity sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ= + dependencies: + boom "2.x.x" + cryptiles "2.x.x" + hoek "2.x.x" + sntp "1.x.x" + hdr-histogram-js@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-1.2.0.tgz#1213c0b317f39b9c05bc4f208cb7931dbbc192ae" @@ -15462,6 +15516,11 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoek@2.x.x: + version "2.16.3" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" + integrity sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0= + hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.5, hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -15745,6 +15804,15 @@ http-proxy@^1.17.0, http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +http-signature@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" + integrity sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8= + dependencies: + assert-plus "^0.2.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -19382,6 +19450,11 @@ mime-db@1.44.0, mime-db@1.x.x, "mime-db@>= 1.40.0 < 2": resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== +mime-db@1.45.0: + version "1.45.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" + integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== + mime-types@^2.0.1, mime-types@^2.1.12, mime-types@^2.1.26, mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.27" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" @@ -19389,6 +19462,13 @@ mime-types@^2.0.1, mime-types@^2.1.12, mime-types@^2.1.26, mime-types@^2.1.27, m dependencies: mime-db "1.44.0" +mime-types@~2.1.7: + version "2.1.28" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd" + integrity sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ== + dependencies: + mime-db "1.45.0" + mime@1.6.0, mime@^1.2.11, mime@^1.3.4, mime@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -20506,6 +20586,11 @@ nyc@^15.0.1: test-exclude "^6.0.0" yargs "^15.0.2" +oauth-sign@~0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" + integrity sha1-Rqarfwrq2N6unsBWV4C31O/rnUM= + oauth-sign@~0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" @@ -22463,7 +22548,7 @@ punycode@1.3.2: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4, punycode@^1.3.2: +punycode@^1.2.4, punycode@^1.3.2, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= @@ -22545,6 +22630,11 @@ qs@^6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== +qs@~6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" + integrity sha1-E+JtKK1rD/qpExLNO/cI7TUecjM= + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -24204,7 +24294,35 @@ request-promise@^4.2.2: stealthy-require "^1.1.1" tough-cookie "^2.3.3" -request@2.81.0, request@^2.44.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: +request@2.81.0: + version "2.81.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" + integrity sha1-xpKJRqDgbF+Nb4qTM0af/aRimKA= + dependencies: + aws-sign2 "~0.6.0" + aws4 "^1.2.1" + caseless "~0.12.0" + combined-stream "~1.0.5" + extend "~3.0.0" + forever-agent "~0.6.1" + form-data "~2.1.1" + har-validator "~4.2.1" + hawk "~3.1.3" + http-signature "~1.1.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.7" + oauth-sign "~0.8.1" + performance-now "^0.2.0" + qs "~6.4.0" + safe-buffer "^5.0.1" + stringstream "~0.0.4" + tough-cookie "~2.3.0" + tunnel-agent "^0.6.0" + uuid "^3.0.0" + +request@^2.44.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== @@ -25281,6 +25399,13 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^2.0.0" +sntp@1.x.x: + version "1.0.9" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" + integrity sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg= + dependencies: + hoek "2.x.x" + sockjs-client@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.4.0.tgz#c9f2568e19c8fd8173b4997ea3420e0bb306c7d5" @@ -26027,6 +26152,11 @@ stringify-entities@^3.0.1: is-decimal "^1.0.2" is-hexadecimal "^1.0.0" +stringstream@~0.0.4: + version "0.0.6" + resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.6.tgz#7880225b0d4ad10e30927d167a1d6f2fd3b33a72" + integrity sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA== + strip-ansi@*, strip-ansi@5.2.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" @@ -27102,6 +27232,13 @@ tough-cookie@^4.0.0: punycode "^2.1.1" universalify "^0.1.2" +tough-cookie@~2.3.0: + version "2.3.4" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.4.tgz#ec60cee38ac675063ffc97a5c18970578ee83655" + integrity sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA== + dependencies: + punycode "^1.4.1" + tr46@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" @@ -28153,7 +28290,7 @@ uuid@^2.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho= -uuid@^3.3.2, uuid@^3.3.3, uuid@^3.4.0: +uuid@^3.0.0, uuid@^3.3.2, uuid@^3.3.3, uuid@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==