From 3c17704cfcab9c93961ca24d5057783e05c04f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Fri, 10 Sep 2021 15:22:46 +0200 Subject: [PATCH 01/10] [APM] Adding lambda icon (#111834) --- .../apm/public/components/shared/agent_icon/get_agent_icon.ts | 2 ++ .../apm/public/components/shared/agent_icon/icons/lambda.svg | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 x-pack/plugins/apm/public/components/shared/agent_icon/icons/lambda.svg diff --git a/x-pack/plugins/apm/public/components/shared/agent_icon/get_agent_icon.ts b/x-pack/plugins/apm/public/components/shared/agent_icon/get_agent_icon.ts index b3551e7ccebcb..e361d72eb2ce8 100644 --- a/x-pack/plugins/apm/public/components/shared/agent_icon/get_agent_icon.ts +++ b/x-pack/plugins/apm/public/components/shared/agent_icon/get_agent_icon.ts @@ -19,6 +19,7 @@ import goIcon from './icons/go.svg'; import iosIcon from './icons/ios.svg'; import darkIosIcon from './icons/ios_dark.svg'; import javaIcon from './icons/java.svg'; +import lambdaIcon from './icons/lambda.svg'; import nodeJsIcon from './icons/nodejs.svg'; import ocamlIcon from './icons/ocaml.svg'; import openTelemetryIcon from './icons/opentelemetry.svg'; @@ -37,6 +38,7 @@ const agentIcons: { [key: string]: string } = { go: goIcon, ios: iosIcon, java: javaIcon, + lambda: lambdaIcon, nodejs: nodeJsIcon, ocaml: ocamlIcon, opentelemetry: openTelemetryIcon, diff --git a/x-pack/plugins/apm/public/components/shared/agent_icon/icons/lambda.svg b/x-pack/plugins/apm/public/components/shared/agent_icon/icons/lambda.svg new file mode 100644 index 0000000000000..2ecd8c5e916b4 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/agent_icon/icons/lambda.svg @@ -0,0 +1,4 @@ + + + + From 7fff05f4e7f5bbe5e8b796d8f773e90028a458a2 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Fri, 10 Sep 2021 15:47:06 +0200 Subject: [PATCH 02/10] [Graph] Switch to SavedObjectClient.resolve (#109617) --- x-pack/plugins/graph/kibana.json | 27 +++++- x-pack/plugins/graph/public/application.ts | 2 + .../graph/public/apps/workspace_route.tsx | 16 ++-- .../workspace_layout.test.tsx | 63 ++++++++++++ .../workspace_layout/workspace_layout.tsx | 38 +++++++- .../public/helpers/saved_workspace_utils.ts | 46 ++++++--- .../helpers/use_workspace_loader.test.tsx | 95 +++++++++++++++++++ .../public/helpers/use_workspace_loader.ts | 85 ++++++++++++----- x-pack/plugins/graph/public/plugin.ts | 3 + x-pack/plugins/graph/public/services/url.ts | 4 +- x-pack/plugins/graph/tsconfig.json | 3 +- 11 files changed, 325 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.test.tsx create mode 100644 x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx diff --git a/x-pack/plugins/graph/kibana.json b/x-pack/plugins/graph/kibana.json index cc732e67995ba..410a5e2c160d6 100644 --- a/x-pack/plugins/graph/kibana.json +++ b/x-pack/plugins/graph/kibana.json @@ -4,12 +4,29 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["licensing", "data", "navigation", "savedObjects", "kibanaLegacy"], - "optionalPlugins": ["home", "features"], - "configPath": ["xpack", "graph"], - "requiredBundles": ["kibanaUtils", "kibanaReact", "home"], + "requiredPlugins": [ + "licensing", + "data", + "navigation", + "savedObjects", + "kibanaLegacy" + ], + "optionalPlugins": [ + "home", + "features", + "spaces" + ], + "configPath": [ + "xpack", + "graph" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "home" + ], "owner": { "name": "Data Discovery", "githubTeam": "kibana-data-discovery" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/graph/public/application.ts b/x-pack/plugins/graph/public/application.ts index 7461a7b5fc172..fc6c6170509d9 100644 --- a/x-pack/plugins/graph/public/application.ts +++ b/x-pack/plugins/graph/public/application.ts @@ -31,6 +31,7 @@ import './index.scss'; import { SavedObjectsStart } from '../../../../src/plugins/saved_objects/public'; import { GraphSavePolicy } from './types'; import { graphRouter } from './router'; +import { SpacesApi } from '../../spaces/public'; /** * These are dependencies of the Graph app besides the base dependencies @@ -63,6 +64,7 @@ export interface GraphDependencies { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; uiSettings: IUiSettingsClient; history: ScopedHistory; + spaces?: SpacesApi; } export type GraphServices = Omit; diff --git a/x-pack/plugins/graph/public/apps/workspace_route.tsx b/x-pack/plugins/graph/public/apps/workspace_route.tsx index f4158238c33c6..55f481bf504f1 100644 --- a/x-pack/plugins/graph/public/apps/workspace_route.tsx +++ b/x-pack/plugins/graph/public/apps/workspace_route.tsx @@ -8,7 +8,7 @@ import React, { useMemo, useRef, useState } from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { Provider } from 'react-redux'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { showSaveModal } from '../../../../../src/plugins/saved_objects/public'; import { Workspace } from '../types'; @@ -40,6 +40,7 @@ export const WorkspaceRoute = ({ getBasePath, addBasePath, setHeaderActionMenu, + spaces, indexPatterns: getIndexPatternProvider, }, }: WorkspaceRouteProps) => { @@ -56,7 +57,6 @@ export const WorkspaceRoute = ({ */ const [renderCounter, setRenderCounter] = useState(0); const history = useHistory(); - const urlQuery = new URLSearchParams(useLocation().search).get('query'); const indexPatternProvider = useMemo( () => createCachedIndexPatternProvider(getIndexPatternProvider.get), @@ -114,22 +114,27 @@ export const WorkspaceRoute = ({ }) ); - const { savedWorkspace, indexPatterns } = useWorkspaceLoader({ + const loaded = useWorkspaceLoader({ workspaceRef, store, savedObjectsClient, - toastNotifications, + spaces, + coreStart, }); - if (!savedWorkspace || !indexPatterns) { + if (!loaded) { return null; } + const { savedWorkspace, indexPatterns, sharingSavedObjectProps } = loaded; + return ( diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.test.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.test.tsx new file mode 100644 index 0000000000000..c535c9e32d335 --- /dev/null +++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.test.tsx @@ -0,0 +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. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { WorkspaceLayoutComponent } from '.'; +import { coreMock } from 'src/core/public/mocks'; +import { spacesPluginMock } from '../../../../spaces/public/mocks'; +import { NavigationPublicPluginStart as NavigationStart } from '../../../../../../src/plugins/navigation/public'; +import { GraphSavePolicy, GraphWorkspaceSavedObject, IndexPatternProvider } from '../../types'; +import { OverlayStart, Capabilities } from 'kibana/public'; +import { SharingSavedObjectProps } from '../../helpers/use_workspace_loader'; + +jest.mock('react-router-dom', () => { + const useLocation = () => ({ + search: '?query={}', + }); + return { + useLocation, + }; +}); + +describe('workspace_layout', () => { + const defaultProps = { + renderCounter: 1, + loading: false, + savedWorkspace: { id: 'test' } as GraphWorkspaceSavedObject, + hasFields: true, + overlays: {} as OverlayStart, + workspaceInitialized: true, + indexPatterns: [], + indexPatternProvider: {} as IndexPatternProvider, + capabilities: {} as Capabilities, + coreStart: coreMock.createStart(), + graphSavePolicy: 'configAndDataWithConsent' as GraphSavePolicy, + navigation: {} as NavigationStart, + canEditDrillDownUrls: true, + setHeaderActionMenu: jest.fn(), + sharingSavedObjectProps: { + outcome: 'exactMatch', + aliasTargetId: '', + } as SharingSavedObjectProps, + spaces: spacesPluginMock.createStartContract(), + }; + it('should display conflict notification if outcome is conflict', () => { + shallow( + + ); + expect(defaultProps.spaces.ui.components.getLegacyUrlConflict).toHaveBeenCalledWith({ + currentObjectId: 'test', + objectNoun: 'Graph', + otherObjectId: 'conflictId', + otherObjectPath: '#/workspace/conflictId?query={}', + }); + }); +}); diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx index 70e5b82ec6526..5426ae9228518 100644 --- a/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx +++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx @@ -9,6 +9,7 @@ import React, { Fragment, memo, useCallback, useRef, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; import { connect } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { SearchBar } from '../search_bar'; import { GraphState, @@ -33,6 +34,8 @@ import { GraphServices } from '../../application'; import { ControlPanel } from '../control_panel'; import { GraphVisualization } from '../graph_visualization'; import { colorChoices } from '../../helpers/style_choices'; +import { SharingSavedObjectProps } from '../../helpers/use_workspace_loader'; +import { getEditUrl } from '../../services/url'; /** * Each component, which depends on `worksapce` @@ -51,6 +54,7 @@ type WorkspaceLayoutProps = Pick< | 'coreStart' | 'canEditDrillDownUrls' | 'overlays' + | 'spaces' > & { renderCounter: number; workspace?: Workspace; @@ -58,7 +62,7 @@ type WorkspaceLayoutProps = Pick< indexPatterns: IndexPatternSavedObject[]; savedWorkspace: GraphWorkspaceSavedObject; indexPatternProvider: IndexPatternProvider; - urlQuery: string | null; + sharingSavedObjectProps?: SharingSavedObjectProps; }; interface WorkspaceLayoutStateProps { @@ -66,7 +70,7 @@ interface WorkspaceLayoutStateProps { hasFields: boolean; } -const WorkspaceLayoutComponent = ({ +export const WorkspaceLayoutComponent = ({ renderCounter, workspace, loading, @@ -81,8 +85,9 @@ const WorkspaceLayoutComponent = ({ graphSavePolicy, navigation, canEditDrillDownUrls, - urlQuery, setHeaderActionMenu, + sharingSavedObjectProps, + spaces, }: WorkspaceLayoutProps & WorkspaceLayoutStateProps) => { const [currentIndexPattern, setCurrentIndexPattern] = useState(); const [showInspect, setShowInspect] = useState(false); @@ -90,6 +95,10 @@ const WorkspaceLayoutComponent = ({ const [mergeCandidates, setMergeCandidates] = useState([]); const [control, setControl] = useState('none'); const selectedNode = useRef(undefined); + + const search = useLocation().search; + const urlQuery = new URLSearchParams(search).get('query'); + const isInitialized = Boolean(workspaceInitialized || savedWorkspace.id); const selectSelected = useCallback((node: WorkspaceNode) => { @@ -154,6 +163,27 @@ const WorkspaceLayoutComponent = ({ [] ); + const getLegacyUrlConflictCallout = useCallback(() => { + // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario + const currentObjectId = savedWorkspace.id; + if (spaces && sharingSavedObjectProps?.outcome === 'conflict' && currentObjectId) { + // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a + // callout with a warning for the user, and provide a way for them to navigate to the other object. + const otherObjectId = sharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'conflict' + const otherObjectPath = + getEditUrl(coreStart.http.basePath.prepend, { id: otherObjectId }) + search; + return spaces.ui.components.getLegacyUrlConflict({ + objectNoun: i18n.translate('xpack.graph.legacyUrlConflict.objectNoun', { + defaultMessage: 'Graph', + }), + currentObjectId, + otherObjectId, + otherObjectPath, + }); + } + return null; + }, [savedWorkspace.id, sharingSavedObjectProps, spaces, coreStart.http, search]); + return ( - {isInitialized && }
+ {getLegacyUrlConflictCallout()} {!isInitialized && (
savedWorkspaceType, + ...defaultsProps, + } as GraphWorkspaceSavedObject, + }; +} + export async function getSavedWorkspace( savedObjectsClient: SavedObjectsClientContract, - id?: string + id: string ) { - const savedObject = { - id, - displayName: 'graph workspace', - getEsType: () => savedWorkspaceType, - } as { [key: string]: any }; - - if (!id) { - assign(savedObject, defaultsProps); - return Promise.resolve(savedObject); - } + const resolveResult = await savedObjectsClient.resolve>( + savedWorkspaceType, + id + ); - const resp = await savedObjectsClient.get>(savedWorkspaceType, id); - savedObject._source = cloneDeep(resp.attributes); + const resp = resolveResult.saved_object; if (!resp._version) { throw new SavedObjectNotFound(savedWorkspaceType, id || ''); } + const savedObject = { + id, + displayName: 'graph workspace', + getEsType: () => savedWorkspaceType, + _source: cloneDeep(resp.attributes), + } as GraphWorkspaceSavedObject; + // assign the defaults to the response defaults(savedObject._source, defaultsProps); @@ -120,7 +130,15 @@ export async function getSavedWorkspace( injectReferences(savedObject, resp.references); } - return savedObject as GraphWorkspaceSavedObject; + const sharingSavedObjectProps = { + outcome: resolveResult.outcome, + aliasTargetId: resolveResult.alias_target_id, + }; + + return { + savedObject, + sharingSavedObjectProps, + }; } export function deleteSavedWorkspace( diff --git a/x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx b/x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx new file mode 100644 index 0000000000000..db80289d0d32d --- /dev/null +++ b/x-pack/plugins/graph/public/helpers/use_workspace_loader.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 React from 'react'; +import { mount } from 'enzyme'; +import { useWorkspaceLoader, UseWorkspaceLoaderProps } from './use_workspace_loader'; +import { coreMock } from 'src/core/public/mocks'; +import { spacesPluginMock } from '../../../spaces/public/mocks'; +import { createMockGraphStore } from '../state_management/mocks'; +import { Workspace } from '../types'; +import { SavedObjectsClientCommon } from 'src/plugins/data/common'; +import { act } from 'react-dom/test-utils'; + +jest.mock('react-router-dom', () => { + const useLocation = () => ({ + search: '?query={}', + }); + + const replaceFn = jest.fn(); + + const useHistory = () => ({ + replace: replaceFn, + }); + return { + useHistory, + useLocation, + useParams: () => ({ + id: '1', + }), + }; +}); + +const mockSavedObjectsClient = ({ + resolve: jest.fn().mockResolvedValue({ + saved_object: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } }, + outcome: 'exactMatch', + }), + find: jest.fn().mockResolvedValue({ title: 'test' }), +} as unknown) as SavedObjectsClientCommon; + +async function setup(props: UseWorkspaceLoaderProps) { + const returnVal = {}; + function TestComponent() { + Object.assign(returnVal, useWorkspaceLoader(props)); + return null; + } + await act(async () => { + const promise = Promise.resolve(); + mount(); + await act(() => promise); + }); + return returnVal; +} + +describe('use_workspace_loader', () => { + const defaultProps = { + workspaceRef: { current: {} as Workspace }, + store: createMockGraphStore({}).store, + savedObjectsClient: mockSavedObjectsClient, + coreStart: coreMock.createStart(), + spaces: spacesPluginMock.createStartContract(), + }; + + it('should not redirect if outcome is exactMatch', async () => { + await act(async () => { + await setup((defaultProps as unknown) as UseWorkspaceLoaderProps); + }); + expect(defaultProps.spaces.ui.redirectLegacyUrl).not.toHaveBeenCalled(); + expect(defaultProps.store.dispatch).toHaveBeenCalled(); + }); + it('should redirect if outcome is aliasMatch', async () => { + const props = { + ...defaultProps, + spaces: spacesPluginMock.createStartContract(), + savedObjectsClient: { + ...mockSavedObjectsClient, + resolve: jest.fn().mockResolvedValue({ + saved_object: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } }, + outcome: 'aliasMatch', + alias_target_id: 'aliasTargetId', + }), + }, + }; + await act(async () => { + await setup((props as unknown) as UseWorkspaceLoaderProps); + }); + expect(props.spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith( + '#/workspace/aliasTargetId?query={}', + 'Graph' + ); + }); +}); diff --git a/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts index 8b91546d52446..b9abf31e084fe 100644 --- a/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts +++ b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts @@ -5,35 +5,48 @@ * 2.0. */ -import { SavedObjectsClientContract, ToastsStart } from 'kibana/public'; +import { SavedObjectsClientContract } from 'kibana/public'; import { useEffect, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'kibana/public'; import { GraphStore } from '../state_management'; import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types'; -import { getSavedWorkspace } from './saved_workspace_utils'; - -interface UseWorkspaceLoaderProps { +import { getEmptyWorkspace, getSavedWorkspace } from './saved_workspace_utils'; +import { getEditUrl } from '../services/url'; +import { SpacesApi } from '../../../spaces/public'; +export interface UseWorkspaceLoaderProps { store: GraphStore; workspaceRef: React.MutableRefObject; savedObjectsClient: SavedObjectsClientContract; - toastNotifications: ToastsStart; + coreStart: CoreStart; + spaces?: SpacesApi; } interface WorkspaceUrlParams { id?: string; } +export interface SharingSavedObjectProps { + outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; + aliasTargetId?: string; +} + +interface WorkspaceLoadedState { + savedWorkspace: GraphWorkspaceSavedObject; + indexPatterns: IndexPatternSavedObject[]; + sharingSavedObjectProps?: SharingSavedObjectProps; +} export const useWorkspaceLoader = ({ + coreStart, + spaces, workspaceRef, store, savedObjectsClient, - toastNotifications, }: UseWorkspaceLoaderProps) => { - const [indexPatterns, setIndexPatterns] = useState(); - const [savedWorkspace, setSavedWorkspace] = useState(); - const history = useHistory(); - const location = useLocation(); + const [state, setState] = useState(); + const { replace: historyReplace } = useHistory(); + const { search } = useLocation(); const { id } = useParams(); /** @@ -41,7 +54,7 @@ export const useWorkspaceLoader = ({ * on changes in id parameter and URL query only. */ useEffect(() => { - const urlQuery = new URLSearchParams(location.search).get('query'); + const urlQuery = new URLSearchParams(search).get('query'); function loadWorkspace( fetchedSavedWorkspace: GraphWorkspaceSavedObject, @@ -71,24 +84,43 @@ export const useWorkspaceLoader = ({ .then((response) => response.savedObjects); } - async function fetchSavedWorkspace() { - return (id + async function fetchSavedWorkspace(): Promise<{ + savedObject: GraphWorkspaceSavedObject; + sharingSavedObjectProps?: SharingSavedObjectProps; + }> { + return id ? await getSavedWorkspace(savedObjectsClient, id).catch(function (e) { - toastNotifications.addError(e, { + coreStart.notifications.toasts.addError(e, { title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { defaultMessage: "Couldn't load graph with ID", }), }); - history.replace('/home'); + historyReplace('/home'); // return promise that never returns to prevent the controller from loading return new Promise(() => {}); }) - : await getSavedWorkspace(savedObjectsClient)) as GraphWorkspaceSavedObject; + : getEmptyWorkspace(); } async function initializeWorkspace() { const fetchedIndexPatterns = await fetchIndexPatterns(); - const fetchedSavedWorkspace = await fetchSavedWorkspace(); + const { + savedObject: fetchedSavedWorkspace, + sharingSavedObjectProps: fetchedSharingSavedObjectProps, + } = await fetchSavedWorkspace(); + + if (spaces && fetchedSharingSavedObjectProps?.outcome === 'aliasMatch') { + // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash + const newObjectId = fetchedSharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'aliasMatch' + const newPath = getEditUrl(coreStart.http.basePath.prepend, { id: newObjectId }) + search; + spaces.ui.redirectLegacyUrl( + newPath, + i18n.translate('xpack.graph.legacyUrlConflict.objectNoun', { + defaultMessage: 'Graph', + }) + ); + return null; + } /** * Deal with situation of request to open saved workspace. Otherwise clean up store, @@ -99,22 +131,25 @@ export const useWorkspaceLoader = ({ } else if (workspaceRef.current) { clearStore(); } - - setIndexPatterns(fetchedIndexPatterns); - setSavedWorkspace(fetchedSavedWorkspace); + setState({ + savedWorkspace: fetchedSavedWorkspace, + indexPatterns: fetchedIndexPatterns, + sharingSavedObjectProps: fetchedSharingSavedObjectProps, + }); } initializeWorkspace(); }, [ id, - location, + search, store, - history, + historyReplace, savedObjectsClient, - setSavedWorkspace, - toastNotifications, + setState, + coreStart, workspaceRef, + spaces, ]); - return { savedWorkspace, indexPatterns }; + return state; }; diff --git a/x-pack/plugins/graph/public/plugin.ts b/x-pack/plugins/graph/public/plugin.ts index 1ff9afe505a3b..1c44714a2c498 100644 --- a/x-pack/plugins/graph/public/plugin.ts +++ b/x-pack/plugins/graph/public/plugin.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { BehaviorSubject } from 'rxjs'; +import { SpacesApi } from '../../spaces/public'; import { AppNavLinkStatus, AppUpdater, @@ -44,6 +45,7 @@ export interface GraphPluginStartDependencies { savedObjects: SavedObjectsStart; kibanaLegacy: KibanaLegacyStart; home?: HomePublicPluginStart; + spaces?: SpacesApi; } export class GraphPlugin @@ -110,6 +112,7 @@ export class GraphPlugin overlays: coreStart.overlays, savedObjects: pluginsStart.savedObjects, uiSettings: core.uiSettings, + spaces: pluginsStart.spaces, }); }, }); diff --git a/x-pack/plugins/graph/public/services/url.ts b/x-pack/plugins/graph/public/services/url.ts index e45d1f0d662be..b33fdc82d8642 100644 --- a/x-pack/plugins/graph/public/services/url.ts +++ b/x-pack/plugins/graph/public/services/url.ts @@ -18,13 +18,13 @@ export function getNewPath() { return '/workspace'; } -export function getEditPath({ id }: GraphWorkspaceSavedObject) { +export function getEditPath({ id }: Pick) { return `/workspace/${id}`; } export function getEditUrl( addBasePath: (url: string) => string, - workspace: GraphWorkspaceSavedObject + workspace: Pick ) { return addBasePath(`#${getEditPath(workspace)}`); } diff --git a/x-pack/plugins/graph/tsconfig.json b/x-pack/plugins/graph/tsconfig.json index d655f28c4e46e..6a5623b311d5e 100644 --- a/x-pack/plugins/graph/tsconfig.json +++ b/x-pack/plugins/graph/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../../../src/plugins/kibana_legacy/tsconfig.json"}, { "path": "../../../src/plugins/home/tsconfig.json"}, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_react/tsconfig.json" } + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" } ] } From 2021e6ecd67d148a2d763d0d5cfbf0102a8e0e8e Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Fri, 10 Sep 2021 09:00:18 -0500 Subject: [PATCH 03/10] [Metrics UI] Add Inventory Timeline open/close state to context and URL state (#111034) * Add urlstate to timeline open/close state * Move open/close state to WaffleOptions and add to saved view --- .../inventory_view/components/bottom_drawer.tsx | 14 +++++++++++--- .../components/waffle/conditional_tooltip.test.tsx | 1 + .../inventory_view/hooks/use_waffle_options.ts | 9 ++++++++- .../inventory_view/hooks/use_waffle_view_state.ts | 3 +++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx index fe0fbeecf8408..31bc09f9d4dd8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx @@ -5,11 +5,12 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { useUiTracker } from '../../../../../../observability/public'; +import { useWaffleOptionsContext } from '../hooks/use_waffle_options'; import { InfraFormatter } from '../../../../lib/lib'; import { Timeline } from './timeline/timeline'; @@ -28,13 +29,20 @@ export const BottomDrawer: React.FC<{ formatter: InfraFormatter; width: number; }> = ({ measureRef, width, interval, formatter, children }) => { - const [isOpen, setIsOpen] = useState(false); + const { timelineOpen, changeTimelineOpen } = useWaffleOptionsContext(); + + const [isOpen, setIsOpen] = useState(Boolean(timelineOpen)); + + useEffect(() => { + if (isOpen !== timelineOpen) setIsOpen(Boolean(timelineOpen)); + }, [isOpen, timelineOpen]); const trackDrawerOpen = useUiTracker({ app: 'infra_metrics' }); const onClick = useCallback(() => { if (!isOpen) trackDrawerOpen({ metric: 'open_timeline_drawer__inventory' }); setIsOpen(!isOpen); - }, [isOpen, trackDrawerOpen]); + changeTimelineOpen(!isOpen); + }, [isOpen, trackDrawerOpen, changeTimelineOpen]); return ( diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx index ac4fac394dacc..d8b578769a1cb 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx @@ -130,6 +130,7 @@ const mockedUseWaffleOptionsContexReturnValue: ReturnType {}), changeLegend: jest.fn(() => {}), changeSort: jest.fn(() => {}), + changeTimelineOpen: jest.fn(() => {}), setWaffleOptionsState: jest.fn(() => {}), boundsOverride: { max: 1, min: 0 }, autoBounds: true, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts index 0ba8398fa4c42..8767be4f8a27e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts @@ -44,6 +44,7 @@ export const DEFAULT_WAFFLE_OPTIONS_STATE: WaffleOptionsState = { legend: DEFAULT_LEGEND, source: 'default', sort: { by: 'name', direction: 'desc' }, + timelineOpen: false, }; export const useWaffleOptions = () => { @@ -134,6 +135,11 @@ export const useWaffleOptions = () => { setCustomMetrics(state.customMetrics); }, [state, inventoryPrefill]); + const changeTimelineOpen = useCallback( + (timelineOpen: boolean) => setState((previous) => ({ ...previous, timelineOpen })), + [setState] + ); + return { ...DEFAULT_WAFFLE_OPTIONS_STATE, ...state, @@ -149,6 +155,7 @@ export const useWaffleOptions = () => { changeCustomMetrics, changeLegend, changeSort, + changeTimelineOpen, setWaffleOptionsState: setState, }; }; @@ -188,7 +195,7 @@ export const WaffleOptionsStateRT = rt.intersection([ customMetrics: rt.array(SnapshotCustomMetricInputRT), sort: WaffleSortOptionRT, }), - rt.partial({ source: rt.string, legend: WaffleLegendOptionsRT }), + rt.partial({ source: rt.string, legend: WaffleLegendOptionsRT, timelineOpen: rt.boolean }), ]); export type WaffleSortOption = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts index 91f1859899177..02a2144f1282e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts @@ -39,6 +39,7 @@ export const useWaffleViewState = () => { region, legend, sort, + timelineOpen, setWaffleOptionsState, } = useWaffleOptionsContext(); const { currentTime, isAutoReloading, setWaffleTimeState } = useWaffleTimeContext(); @@ -60,6 +61,7 @@ export const useWaffleViewState = () => { autoReload: isAutoReloading, filterQuery, legend, + timelineOpen, }; const onViewChange = useCallback( @@ -77,6 +79,7 @@ export const useWaffleViewState = () => { accountId: newState.accountId, region: newState.region, legend: newState.legend, + timelineOpen: newState.timelineOpen, }); if (newState.time) { From 6a3e68956eee410e31a62b68b1785042fb580789 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Fri, 10 Sep 2021 16:16:24 +0200 Subject: [PATCH 04/10] Fix extra white space on the alert table whe page size is 50 or 100 (#111568) --- .../components/t_grid/body/height_hack.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts b/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts index 47cd1ed92d661..5371d7004a864 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts @@ -7,9 +7,6 @@ import { useState, useLayoutEffect } from 'react'; -// That could be different from security and observability. Get it as parameter? -const INITIAL_DATA_GRID_HEIGHT = 967; - // It will recalculate DataGrid height after this time interval. const TIME_INTERVAL = 50; @@ -18,8 +15,17 @@ const TIME_INTERVAL = 50; * 3 (three) is a number, numeral and digit. It is the natural number following 2 and preceding 4, and is the smallest * odd prime number and the only prime preceding a square number. It has religious or cultural significance in many societies. */ + const MAGIC_GAP = 3; +// Hard coded height for every page size +const DATA_GRID_HEIGHT_BY_PAGE_SIZE: { [key: number]: number } = { + 10: 457, + 25: 967, + 50: 1817, + 100: 3517, +}; + /** * HUGE HACK!!! * DataGrtid height isn't properly calculated when the grid has horizontal scroll. @@ -30,13 +36,15 @@ const MAGIC_GAP = 3; * Please delete me and allow DataGrid to calculate its height when the bug is fixed. */ export const useDataGridHeightHack = (pageSize: number, rowCount: number) => { - const [height, setHeight] = useState(INITIAL_DATA_GRID_HEIGHT); + const [height, setHeight] = useState(DATA_GRID_HEIGHT_BY_PAGE_SIZE[pageSize]); useLayoutEffect(() => { setTimeout(() => { const gridVirtualized = document.querySelector('#body-data-grid .euiDataGrid__virtualized'); - if ( + if (rowCount === pageSize) { + setHeight(DATA_GRID_HEIGHT_BY_PAGE_SIZE[pageSize]); + } else if ( gridVirtualized && gridVirtualized.children[0].clientHeight !== gridVirtualized.clientHeight // check if it has vertical scroll ) { From 7f6df4a5756764de9743ff0015d2e3382077d7f8 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher <471693+Kerry350@users.noreply.github.com> Date: Fri, 10 Sep 2021 15:26:53 +0100 Subject: [PATCH 05/10] [RAC] [Observability] Expand Observability alerts page functional tests (#111297) * Regenerate data and add tests --- .../pages/alerts/alerts_flyout/index.tsx | 20 ++- .../pages/alerts/alerts_table_t_grid.tsx | 1 + .../public/components/t_grid/shared/index.tsx | 2 +- .../observability/alerts/data.json.gz | Bin 1745 -> 2080 bytes .../observability/alerts/mappings.json | 24 ++- .../services/observability/alerts.ts | 129 ++++++++++++++++ .../services/observability/index.ts | 3 + .../apps/observability/alerts/index.ts | 138 ++++++++++++++++-- 8 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 x-pack/test/functional/services/observability/alerts.ts diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx index c4d455fb43b7f..fc246641101e3 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx @@ -127,9 +127,9 @@ export function AlertsFlyout({ ]; return ( - + - +

{alertData.fields[ALERT_RULE_NAME]}

@@ -141,13 +141,27 @@ export function AlertsFlyout({ compressed={true} type="responsiveColumn" listItems={overviewListItems} + titleProps={ + { + 'data-test-subj': 'alertsFlyoutDescriptionListTitle', + } as any // NOTE / TODO: This "any" is a temporary workaround: https://github.com/elastic/eui/issues/5148 + } + descriptionProps={ + { + 'data-test-subj': 'alertsFlyoutDescriptionListDescription', + } as any // NOTE / TODO: This "any" is a temporary workaround: https://github.com/elastic/eui/issues/5148 + } /> {alertData.link && !isInApp && ( - + View in app diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx index 2d325b6f3f7c4..bca8c8095511e 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx @@ -254,6 +254,7 @@ function ObservabilityActions({ iconType="expand" color="text" onClick={() => setFlyoutAlert(alert)} + data-test-subj="openFlyoutButton" /> diff --git a/x-pack/plugins/timelines/public/components/t_grid/shared/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/shared/index.tsx index 324a97ff2a39f..f4a9158a3e4e7 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/shared/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/shared/index.tsx @@ -51,7 +51,7 @@ export const TGridEmpty: React.FC<{ height?: keyof typeof heights }> = ({ height const { http } = useKibana().services; return ( - + diff --git a/x-pack/test/functional/es_archives/observability/alerts/data.json.gz b/x-pack/test/functional/es_archives/observability/alerts/data.json.gz index 45da36818828400c441aa7f57e7ed70c0538e223..bcccaeda999c0903d945b4bbeea6c27074dd3c75 100644 GIT binary patch literal 2080 zcmV+*2;cV~iwFP!000026YZN@Z{xTXfZyj=2mxBmEJ`i!7eBQa^r1i(SQPEcEEWN8 za@5$8A<4<6gZ=L%WoMH(wj(97V>=Fz#1eVPW0L3N;W^UxPf2po+|B&OD7kQ@z4%MK zaib^mc;|b#2$#8wrNm-vm|=i3=>YX!1H+jHOnbxywwdyz6^{~y^S?!*G%p%o6?&5D ziLaU(l&1F8jW+otZ|+h(o8A{VpXvFe0Wkic`-bXrUfBm#!kxc1`PA2qp3Y+X7-9sV z0ucWjkr9+5EK))6x21fj?{9q3q}n!lSwxBW>Bg^?Uk8n2X`PRQvJa)#xzUAQ=$N{B zrDHScybg5{gc3>`v|V4@FIrsDP>e851ybKO^x@;!FM4UNDqmlflh_P;vG)C(;q^81 zB8*vKeY%*;IPkuz%IY$FC4X7YjMqgCs`>P?38R}A;|=;%Ue}9({CgBnY&kEQB(IZk zC<#-YG*`MvQ1aQ%t166a(&`l};4euQ1Riy@sf+oT_0=)8t=&{`zH`^sFk~+%Ef?ZT(L1&t){W%XO2m zO>#b8NHE8EMjZsucnc5;f0XtZFoU4g3R4Q{hIYdASIz2z#3P1AM5Ua{Z=2N=a0x0erHmNQ>Gt6k5ujYAk~{IVZ0ux-P4>i0cHjQUujUGS# z!oRlvfBzJJfB*F9dk>DwOfslAuugeE71cmtM*$Sn39n61o`w1y@*dP?%x-5KQvfO0j0Fy)fIjPlCcGVsv5muTLd6{k(} zN7nQTptlyXJEBYLm86*k3dEU85*dw--8 z5{Rkx5p3ZELPmPWS_n)9Mm>=ZcN=T*6CF;57T6L5goPO^+~rl%%<9pPKi=Nnrpxkx z$v06RZVth$(O^HY-wxpIqz`uwhxD9NA+c>q?}j1096=JCcRo-YrUyVy8}l1Xs|WJe znafM$PZo>t&HDf+;Q*$X4tq4SfJ&r+reQ+4!&q~JGK|&+?r#XV4@LA0X2=DC0m!)v^Ce`MFV29VS}Mk<@cT3Uh6VbYVm0G(XFOjC(~QeA&fgX1VJ)&Yp^pcQ(DWe0ndlpcJ}&nLbY>Nl zTxI}sF92np0ga^s&Pk=LHJVEHfq?!h5B;!#K4d->9#TH_%=IDM^sZd*0g4V>Z|&8c z)2Tc6*PY@>NCTK)9#FwCkUFd{u8~0mdP@yECs*G)xrz_KVejzJgYBT)TV_dNAAr}d|p*+hcK5YLDxN+6BdXdjhtGC@GVIq<+3{~#yrCgk8vEe+ zVfCiLbmq~-({>Z;LTafTkw^_j>P42G2j6F?7m<1x{K1fVFxm;mF@>H1g);*v!x=D8 zdSD&#&{>AUI3ED*7)pbU?t^wxVkmfD+y&=2#jJafSuxQGc|W{Jx!BYutSz*953;?4 zm~C8(vWLksXRtQH4(WB$5mYG`bRd@XFz3@_86jCvjS%1^!GLOpfTZ37rD&ERYAI6m z-C5SYdL;?Hf0B8v$NpJ}P(R{EnI&{xzMlr||MaCR>i$v0(Tx{_WA3NL@=5;a;uWaN zi>%UhQ_XENuY4cqJz2bA(v~`;ow)u7TAY-I59z#7HKI7>WGLsYhuxo^^X81DLPHOv z6&g@(88GoYuBCxkTdar@M*_XUGU%^j-WmmmV_xO1e%m$kV9L(SI|1f(&4~A~`~%8} z4+g$WJIYa*luU6Cs8<$98Do$PBg~alPG*MzKFU&o_xAD@7Cpho+nfj$M*uz|*MN`6 zl$eq{zU z37-Tv(py70wD`=yPKVz3AcImzaOhpjK}J(~=HN%uw`S+QA!{P77dAxHs@C?7hhhK+%w*N`E2Lg_QfBy#q K;u$YATmS%fJ^>T} literal 1745 zcmV;?1}^y@iwFP!000026YZN@Z`(E$$KU%Yga8GaVN`j)_-RA2hXE_lhwY_95%4BQ zh%FhqB+ZI__mQ&GB#vWCi;~!B0^}l-JUl);=chj@=i8ekIchE!{%DdMxzZl}9A7xs zvo^l+EnI}l+{IR7l!h4+z#6Xr^;`gjSO&Z^7&;@kk+2&cM-Xna-ZS*mBg zYHCoL+E?e=iFO!Z=Z)!=-l+gSr({6bfT>awluwJYJ3Uz&XG>qgHP(LRP40jK~( zeg(t{2+I5XK&Vui8TEiWEnySH7$dJNH$+Dt4K|^oAE<39YZ|vP!$E zziqZ#pu0vRu5#;BU!3P5qvvIV0>wDw}T_Y9}QuDxiDTAHK^M8 zNfV-)7t`?}k5-JoG1)8I4~^}Ww`BmNcRTPtfD%3MuI*V-Ud+6kZkYHmFn?861Gs*( zP#%xx3M-+#fq>eOPLy&Bv=9myEQp7WSWDD%^PEpGOQj%Bq013w0weK=x*Veek>waY zvnt)6KogKa{%V>;`wZAQU1V-ee&@sT9?01~`P^6@2kvP{AU16>#|kmC#p zrW~M|g+Os_fRjE$NE*Y8KLDVQCD0L(1YxM!8l-k zM0Gv@5igtckF3f^L%KB5L+t=}I;4`Y5@_$kzRGYb!hXu)?@Qhr>9cZLkIZxgQ_1;L zh;)K5LS(Q;?+s_Njm=p;^C1TszzaDwJ2`y2d$65^&?n)ISGli~ck{CHD<~f)bKTg} zaODHz3YSNp>a;I}{|IIW!#AcuANt-h9&RROyGYFCkb{w3Gp)1tIziJ=9c?V$v?G5X z3`;DScBI3w8v(;1zhOMH6O4?cwg=So-Y|HyLkEeV35Ur5) zM*UDg<5(&Bcs!%McxD?r|Jt8tUH4%7m1hUZGd6)DY;o{NJR4BbFOO%=V~UC8z$;1s zrIG<{GYkyIPE)SA#q!4zXo4a5hyo4w0$P23asPhrF3`H}!S*ZA4iRWjO&CiBA4{MO zsOi0Xgf$x2G|!k(!T=$%3{aFQpfr&{K&Y8hRzf270BrG2Ce@QX9-@?EG4y#qoj?>k zqyvu=aUXzwKTuSXZKL8(J3$yN+-Bd**Y%Qp(Urr&-+&XrC#9Z|Jo8F+)YIdf4&sSLHnm=lXw2X^nLIs_(4x9^#I4aum`L8lQs@HI}{`Q0gyl z!>U4YW-?&j8qDIGcR*SccI$=<9uTjnBttnj_o0^T=+X|Q3Olg-{3=;C4_Bw z*vnw5-X8Zw+V4dSYP=X7XCIeYlYF0I2-5N*t90E|t!>)M4}sgQWDSYEnmp|V?kj{? z4?UA?Ovr_MJ4q>3V?nNeoxeQDaUr~57z1t*4_a1*&ASc@Wpzfm(}*#|Um^E!TwP}L zpGU?Ol_`70)r-f~fQo&2xU$YLNvQ_TSqi8(6evzHK$+tbX=GXE_5)Wq{__falH)ch zl|VaawR^W$U)GJEr*_e%P1%HC6=gL~7q;02BMjeeqR}Anv_!`6En$CJti$PlZ4bwS n(;y^;`v)@wk9Hu|338;??xqhx(txTw2qgUnbhE_avpoO+O|VzP diff --git a/x-pack/test/functional/es_archives/observability/alerts/mappings.json b/x-pack/test/functional/es_archives/observability/alerts/mappings.json index 88d12b7d797bb..63750ddafe329 100644 --- a/x-pack/test/functional/es_archives/observability/alerts/mappings.json +++ b/x-pack/test/functional/es_archives/observability/alerts/mappings.json @@ -65,8 +65,12 @@ } } }, - "id": { - "type": "keyword" + "instance": { + "properties": { + "id": { + "type": "keyword" + } + } }, "reason": { "type": "keyword" @@ -325,8 +329,12 @@ } } }, - "id": { - "type": "keyword" + "instance": { + "properties": { + "id": { + "type": "keyword" + } + } }, "reason": { "type": "keyword" @@ -561,8 +569,12 @@ } } }, - "id": { - "type": "keyword" + "instance": { + "properties": { + "id": { + "type": "keyword" + } + } }, "reason": { "type": "keyword" diff --git a/x-pack/test/functional/services/observability/alerts.ts b/x-pack/test/functional/services/observability/alerts.ts new file mode 100644 index 0000000000000..ba7f952b30c64 --- /dev/null +++ b/x-pack/test/functional/services/observability/alerts.ts @@ -0,0 +1,129 @@ +/* + * 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 querystring from 'querystring'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; + +// Based on the x-pack/test/functional/es_archives/observability/alerts archive. +const DATE_WITH_DATA = { + rangeFrom: '2021-09-01T13:36:22.109Z', + rangeTo: '2021-09-03T13:36:22.109Z', +}; + +const ALERTS_FLYOUT_SELECTOR = 'alertsFlyout'; + +export function ObservabilityAlertsProvider({ getPageObjects, getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const flyoutService = getService('flyout'); + const pageObjects = getPageObjects(['common']); + const retry = getService('retry'); + + const navigateToTimeWithData = async () => { + return await pageObjects.common.navigateToUrlWithBrowserHistory( + 'observability', + '/alerts', + `?${querystring.stringify(DATE_WITH_DATA)}` + ); + }; + + const getTableCells = async () => { + // NOTE: This isn't ideal, but EuiDataGrid doesn't really have the concept of "rows" + return await testSubjects.findAll('dataGridRowCell'); + }; + + const getTableOrFail = async () => { + return await testSubjects.existOrFail('events-viewer-panel'); + }; + + const getNoDataStateOrFail = async () => { + return await testSubjects.existOrFail('tGridEmptyState'); + }; + + // Query Bar + const getQueryBar = async () => { + return await testSubjects.find('queryInput'); + }; + + const getQuerySubmitButton = async () => { + return await testSubjects.find('querySubmitButton'); + }; + + const clearQueryBar = async () => { + return await (await getQueryBar()).clearValueWithKeyboard({ charByChar: true }); + }; + + const typeInQueryBar = async (query: string) => { + return await (await getQueryBar()).type(query); + }; + + const submitQuery = async (query: string) => { + await typeInQueryBar(query); + return await (await getQuerySubmitButton()).click(); + }; + + // Flyout + const getOpenFlyoutButton = async () => { + return await testSubjects.find('openFlyoutButton'); + }; + + const openAlertsFlyout = async () => { + await (await getOpenFlyoutButton()).click(); + await retry.waitFor( + 'flyout open', + async () => await testSubjects.exists(ALERTS_FLYOUT_SELECTOR, { timeout: 2500 }) + ); + }; + + const getAlertsFlyout = async () => { + return await testSubjects.find(ALERTS_FLYOUT_SELECTOR); + }; + + const getAlertsFlyoutOrFail = async () => { + return await testSubjects.existOrFail(ALERTS_FLYOUT_SELECTOR); + }; + + const getAlertsFlyoutTitle = async () => { + return await testSubjects.find('alertsFlyoutTitle'); + }; + + const closeAlertsFlyout = async () => { + return await flyoutService.close(ALERTS_FLYOUT_SELECTOR); + }; + + const getAlertsFlyoutViewInAppButtonOrFail = async () => { + return await testSubjects.existOrFail('alertsFlyoutViewInAppButton'); + }; + + const getAlertsFlyoutDescriptionListTitles = async (): Promise => { + const flyout = await getAlertsFlyout(); + return await testSubjects.findAllDescendant('alertsFlyoutDescriptionListTitle', flyout); + }; + + const getAlertsFlyoutDescriptionListDescriptions = async (): Promise => { + const flyout = await getAlertsFlyout(); + return await testSubjects.findAllDescendant('alertsFlyoutDescriptionListDescription', flyout); + }; + + return { + clearQueryBar, + typeInQueryBar, + submitQuery, + getTableCells, + getTableOrFail, + getNoDataStateOrFail, + openAlertsFlyout, + getAlertsFlyout, + getAlertsFlyoutTitle, + closeAlertsFlyout, + navigateToTimeWithData, + getAlertsFlyoutOrFail, + getAlertsFlyoutViewInAppButtonOrFail, + getAlertsFlyoutDescriptionListTitles, + getAlertsFlyoutDescriptionListDescriptions, + }; +} diff --git a/x-pack/test/functional/services/observability/index.ts b/x-pack/test/functional/services/observability/index.ts index 14f931d93b56f..0d167ae5d516e 100644 --- a/x-pack/test/functional/services/observability/index.ts +++ b/x-pack/test/functional/services/observability/index.ts @@ -7,11 +7,14 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { ObservabilityUsersProvider } from './users'; +import { ObservabilityAlertsProvider } from './alerts'; export function ObservabilityProvider(context: FtrProviderContext) { const users = ObservabilityUsersProvider(context); + const alerts = ObservabilityAlertsProvider(context); return { users, + alerts, }; } diff --git a/x-pack/test/observability_functional/apps/observability/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/alerts/index.ts index ae60eff1859ba..d4ed8fdd674f7 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/index.ts @@ -6,32 +6,28 @@ */ import expect from '@kbn/expect'; -import querystring from 'querystring'; import { FtrProviderContext } from '../../../ftr_provider_context'; -// Based on the x-pack/test/functional/es_archives/observability/alerts archive. -const DATE_WITH_DATA = { - rangeFrom: '2021-08-31T13:36:22.109Z', - rangeTo: '2021-09-01T13:36:22.109Z', -}; +async function asyncForEach(array: T[], callback: (item: T, index: number) => void) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index); + } +} export default ({ getPageObjects, getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); - // FLAKY: https://github.com/elastic/kibana/issues/110920 - describe.skip('Observability alerts', function () { + describe('Observability alerts', function () { this.tags('includeFirefox'); const pageObjects = getPageObjects(['common']); const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const observability = getService('observability'); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); - await pageObjects.common.navigateToUrlWithBrowserHistory( - 'observability', - '/alerts', - `?${querystring.stringify(DATE_WITH_DATA)}` - ); + await observability.alerts.navigateToTimeWithData(); }); after(async () => { @@ -40,13 +36,123 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('Alerts table', () => { it('Renders the table', async () => { - await testSubjects.existOrFail('events-viewer-panel'); + await observability.alerts.getTableOrFail(); }); it('Renders the correct number of cells', async () => { // NOTE: This isn't ideal, but EuiDataGrid doesn't really have the concept of "rows" - const cells = await testSubjects.findAll('dataGridRowCell'); - expect(cells.length).to.be(54); + const cells = await observability.alerts.getTableCells(); + expect(cells.length).to.be(72); + }); + + describe('Filtering', () => { + afterEach(async () => { + await observability.alerts.clearQueryBar(); + }); + + after(async () => { + // NOTE: We do this as the query bar takes the place of the datepicker when it is in focus, so we'll reset + // back to default. + await observability.alerts.submitQuery(''); + }); + + it('Autocompletion works', async () => { + await observability.alerts.typeInQueryBar('kibana.alert.s'); + await testSubjects.existOrFail('autocompleteSuggestion-field-kibana.alert.start-'); + await testSubjects.existOrFail('autocompleteSuggestion-field-kibana.alert.status-'); + }); + + it('Applies filters correctly', async () => { + await observability.alerts.submitQuery('kibana.alert.status: recovered'); + await retry.try(async () => { + const cells = await observability.alerts.getTableCells(); + expect(cells.length).to.be(24); + }); + }); + + it('Displays a no data state when filters produce zero results', async () => { + await observability.alerts.submitQuery('kibana.alert.consumer: uptime'); + await observability.alerts.getNoDataStateOrFail(); + }); + }); + + describe('Date selection', () => { + after(async () => { + await observability.alerts.navigateToTimeWithData(); + }); + + it('Correctly applies date picker selections', async () => { + await retry.try(async () => { + await (await testSubjects.find('superDatePickerToggleQuickMenuButton')).click(); + // We shouldn't expect any data for the last 15 minutes + await (await testSubjects.find('superDatePickerCommonlyUsed_Last_15 minutes')).click(); + }); + await observability.alerts.getNoDataStateOrFail(); + await pageObjects.common.waitUntilUrlIncludes('rangeFrom=now-15m&rangeTo=now'); + }); + }); + + describe('Flyout', () => { + it('Can be opened', async () => { + await observability.alerts.openAlertsFlyout(); + await observability.alerts.getAlertsFlyoutOrFail(); + }); + + it('Can be closed', async () => { + await observability.alerts.closeAlertsFlyout(); + await testSubjects.missingOrFail('alertsFlyout'); + }); + + describe('When open', async () => { + before(async () => { + await observability.alerts.openAlertsFlyout(); + }); + + after(async () => { + await observability.alerts.closeAlertsFlyout(); + }); + + it('Displays the correct title', async () => { + const titleText = await ( + await observability.alerts.getAlertsFlyoutTitle() + ).getVisibleText(); + expect(titleText).to.contain('Log threshold'); + }); + + it('Displays the correct content', async () => { + const flyoutTitles = await observability.alerts.getAlertsFlyoutDescriptionListTitles(); + const flyoutDescriptions = await observability.alerts.getAlertsFlyoutDescriptionListDescriptions(); + + const expectedTitles = [ + 'Status', + 'Last updated', + 'Duration', + 'Expected value', + 'Actual value', + 'Rule type', + ]; + const expectedDescriptions = [ + 'Active', + 'Sep 2, 2021 @ 12:54:09.674', + '15 minutes', + '100.25', + '1957', + 'Log threshold', + ]; + + await asyncForEach(flyoutTitles, async (title, index) => { + expect(await title.getVisibleText()).to.be(expectedTitles[index]); + }); + + await asyncForEach(flyoutDescriptions, async (description, index) => { + expect(await description.getVisibleText()).to.be(expectedDescriptions[index]); + }); + }); + + it('Displays a View in App button', async () => { + await observability.alerts.getAlertsFlyoutViewInAppButtonOrFail(); + }); + }); }); }); }); From 5fbc1d4c275a8be40d333f5d2882d7341cc9ecf4 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Fri, 10 Sep 2021 10:38:10 -0400 Subject: [PATCH 06/10] [APM] Removes the beta label from APM tutorial (#111499) (#111828) --- src/plugins/home/common/instruction_variant.ts | 2 +- .../apm/public/tutorial/tutorial_fleet_instructions/index.tsx | 2 +- x-pack/plugins/translations/translations/ja-JP.json | 2 -- x-pack/plugins/translations/translations/zh-CN.json | 2 -- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/plugins/home/common/instruction_variant.ts b/src/plugins/home/common/instruction_variant.ts index f27b2c97bdc1e..66c841cdc8b56 100644 --- a/src/plugins/home/common/instruction_variant.ts +++ b/src/plugins/home/common/instruction_variant.ts @@ -48,7 +48,7 @@ const DISPLAY_MAP = { [INSTRUCTION_VARIANT.LINUX]: 'Linux', [INSTRUCTION_VARIANT.PHP]: 'PHP', [INSTRUCTION_VARIANT.FLEET]: i18n.translate('home.tutorial.instruction_variant.fleet', { - defaultMessage: 'Elastic APM (beta) in Fleet', + defaultMessage: 'Elastic APM in Fleet', }), }; diff --git a/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx b/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx index fbbb2e1ffedf4..c69623f92987a 100644 --- a/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx +++ b/x-pack/plugins/apm/public/tutorial/tutorial_fleet_instructions/index.tsx @@ -80,7 +80,7 @@ function TutorialFleetInstructions({ http, basePath, isDarkTheme }: Props) { display="plain" textAlign="left" title={i18n.translate('xpack.apm.tutorial.apmServer.fleet.title', { - defaultMessage: 'Elastic APM (beta) now available in Fleet!', + defaultMessage: 'Elastic APM now available in Fleet!', })} description={i18n.translate( 'xpack.apm.tutorial.apmServer.fleet.message', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fdb86d261e723..e11a5081e9d62 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2997,7 +2997,6 @@ "home.tutorial.card.sampleDataDescription": "これらの「ワンクリック」データセットで Kibana の探索を始めましょう。", "home.tutorial.card.sampleDataTitle": "サンプルデータ", "home.tutorial.elasticCloudButtonLabel": "Elastic Cloud", - "home.tutorial.instruction_variant.fleet": "FleetのElastic APM(ベータ版)", "home.tutorial.instruction.copyButtonLabel": "スニペットをコピー", "home.tutorial.instructionSet.checkStatusButtonLabel": "ステータスを確認", "home.tutorial.instructionSet.customizeLabel": "コードスニペットのカスタマイズ", @@ -6984,7 +6983,6 @@ "xpack.apm.tutorial.apmServer.fleet.apmIntegration.button": "APM統合", "xpack.apm.tutorial.apmServer.fleet.manageApmIntegration.button": "FleetでAPM統合を管理", "xpack.apm.tutorial.apmServer.fleet.message": "APMA統合は、APMデータ用にElasticsearchテンプレートとIngest Nodeパイプラインをインストールします。", - "xpack.apm.tutorial.apmServer.fleet.title": "Elastic APM(ベータ版)がFleetで提供されました。", "xpack.apm.tutorial.apmServer.statusCheck.btnLabel": "APM Server ステータスを確認", "xpack.apm.tutorial.apmServer.statusCheck.errorMessage": "APM Server が検出されました。7.0 以上に更新され、動作中であることを確認してください。", "xpack.apm.tutorial.apmServer.statusCheck.successMessage": "APM Server が正しくセットアップされました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b50f86fcd6e61..0eb4b569708f8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3026,7 +3026,6 @@ "home.tutorial.card.sampleDataDescription": "开始使用这些“一键式”数据集浏览 Kibana。", "home.tutorial.card.sampleDataTitle": "样例数据", "home.tutorial.elasticCloudButtonLabel": "Elastic Cloud", - "home.tutorial.instruction_variant.fleet": "Fleet 中的 Elastic APM(公测版)", "home.tutorial.instruction.copyButtonLabel": "复制代码片段", "home.tutorial.instructionSet.checkStatusButtonLabel": "检查状态", "home.tutorial.instructionSet.customizeLabel": "定制您的代码片段", @@ -7042,7 +7041,6 @@ "xpack.apm.tutorial.apmServer.fleet.apmIntegration.button": "APM 集成", "xpack.apm.tutorial.apmServer.fleet.manageApmIntegration.button": "在 Fleet 中管理 APM 集成", "xpack.apm.tutorial.apmServer.fleet.message": "APM 集成安装用于 APM 数据的 Elasticsearch 模板和采集节点管道。", - "xpack.apm.tutorial.apmServer.fleet.title": "现在 Fleet 中包含 Elastic APM(公测版)!", "xpack.apm.tutorial.apmServer.statusCheck.btnLabel": "检查 APM Server 状态", "xpack.apm.tutorial.apmServer.statusCheck.errorMessage": "未检测到任何 APM Server。请确保其正在运行并且您已升级到 7.0 或更高版本。", "xpack.apm.tutorial.apmServer.statusCheck.successMessage": "您已正确设置 APM Server", From caf5fe3fb6740be66c5c9bbab5ddd779eb253d3d Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Fri, 10 Sep 2021 07:48:05 -0700 Subject: [PATCH 07/10] [Security Solution] Add host.os.name.caseless mapping and runtime field (#111455) * Add host.os.name.caseless field and runtime field * Tests * Only add backwards compatibility mappings to old indices by version * Always update aliases_version field even if there are no compat mappings * Add test for newest index version * More comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../get_signals_template.test.ts.snap | 609 ++++++++++++++++++ .../routes/index/create_index_route.ts | 27 +- .../routes/index/get_signals_template.test.ts | 12 +- .../routes/index/get_signals_template.ts | 80 ++- .../routes/index/other_mappings.json | 23 + .../basic/tests/query_signals.ts | 44 ++ 6 files changed, 759 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap index 3c065ab0ac109..1d4e84ea5dccf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap @@ -1,5 +1,609 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`get_signals_template backwards compatibility mappings for version 45 should match snapshot 1`] = ` +Object { + "_meta": Object { + "aliases_version": 1, + "version": 45, + }, + "properties": Object { + "kibana.alert.ancestors.depth": Object { + "path": "signal.ancestors.depth", + "type": "alias", + }, + "kibana.alert.ancestors.id": Object { + "path": "signal.ancestors.id", + "type": "alias", + }, + "kibana.alert.ancestors.index": Object { + "path": "signal.ancestors.index", + "type": "alias", + }, + "kibana.alert.ancestors.type": Object { + "path": "signal.ancestors.type", + "type": "alias", + }, + "kibana.alert.depth": Object { + "path": "signal.depth", + "type": "alias", + }, + "kibana.alert.original_event.action": Object { + "path": "signal.original_event.action", + "type": "alias", + }, + "kibana.alert.original_event.category": Object { + "path": "signal.original_event.category", + "type": "alias", + }, + "kibana.alert.original_event.code": Object { + "path": "signal.original_event.code", + "type": "alias", + }, + "kibana.alert.original_event.created": Object { + "path": "signal.original_event.created", + "type": "alias", + }, + "kibana.alert.original_event.dataset": Object { + "path": "signal.original_event.dataset", + "type": "alias", + }, + "kibana.alert.original_event.duration": Object { + "path": "signal.original_event.duration", + "type": "alias", + }, + "kibana.alert.original_event.end": Object { + "path": "signal.original_event.end", + "type": "alias", + }, + "kibana.alert.original_event.hash": Object { + "path": "signal.original_event.hash", + "type": "alias", + }, + "kibana.alert.original_event.id": Object { + "path": "signal.original_event.id", + "type": "alias", + }, + "kibana.alert.original_event.kind": Object { + "path": "signal.original_event.kind", + "type": "alias", + }, + "kibana.alert.original_event.module": Object { + "path": "signal.original_event.module", + "type": "alias", + }, + "kibana.alert.original_event.outcome": Object { + "path": "signal.original_event.outcome", + "type": "alias", + }, + "kibana.alert.original_event.provider": Object { + "path": "signal.original_event.provider", + "type": "alias", + }, + "kibana.alert.original_event.reason": Object { + "path": "signal.original_event.reason", + "type": "alias", + }, + "kibana.alert.original_event.risk_score": Object { + "path": "signal.original_event.risk_score", + "type": "alias", + }, + "kibana.alert.original_event.risk_score_norm": Object { + "path": "signal.original_event.risk_score_norm", + "type": "alias", + }, + "kibana.alert.original_event.sequence": Object { + "path": "signal.original_event.sequence", + "type": "alias", + }, + "kibana.alert.original_event.severity": Object { + "path": "signal.original_event.severity", + "type": "alias", + }, + "kibana.alert.original_event.start": Object { + "path": "signal.original_event.start", + "type": "alias", + }, + "kibana.alert.original_event.timezone": Object { + "path": "signal.original_event.timezone", + "type": "alias", + }, + "kibana.alert.original_event.type": Object { + "path": "signal.original_event.type", + "type": "alias", + }, + "kibana.alert.original_time": Object { + "path": "signal.original_time", + "type": "alias", + }, + "kibana.alert.reason": Object { + "path": "signal.reason", + "type": "alias", + }, + "kibana.alert.risk_score": Object { + "path": "signal.rule.risk_score", + "type": "alias", + }, + "kibana.alert.rule.author": Object { + "path": "signal.rule.author", + "type": "alias", + }, + "kibana.alert.rule.building_block_type": Object { + "path": "signal.rule.building_block_type", + "type": "alias", + }, + "kibana.alert.rule.created_at": Object { + "path": "signal.rule.created_at", + "type": "alias", + }, + "kibana.alert.rule.created_by": Object { + "path": "signal.rule.created_by", + "type": "alias", + }, + "kibana.alert.rule.description": Object { + "path": "signal.rule.description", + "type": "alias", + }, + "kibana.alert.rule.enabled": Object { + "path": "signal.rule.enabled", + "type": "alias", + }, + "kibana.alert.rule.false_positives": Object { + "path": "signal.rule.false_positives", + "type": "alias", + }, + "kibana.alert.rule.from": Object { + "path": "signal.rule.from", + "type": "alias", + }, + "kibana.alert.rule.immutable": Object { + "path": "signal.rule.immutable", + "type": "alias", + }, + "kibana.alert.rule.index": Object { + "path": "signal.rule.index", + "type": "alias", + }, + "kibana.alert.rule.interval": Object { + "path": "signal.rule.interval", + "type": "alias", + }, + "kibana.alert.rule.language": Object { + "path": "signal.rule.language", + "type": "alias", + }, + "kibana.alert.rule.license": Object { + "path": "signal.rule.license", + "type": "alias", + }, + "kibana.alert.rule.max_signals": Object { + "path": "signal.rule.max_signals", + "type": "alias", + }, + "kibana.alert.rule.name": Object { + "path": "signal.rule.name", + "type": "alias", + }, + "kibana.alert.rule.note": Object { + "path": "signal.rule.note", + "type": "alias", + }, + "kibana.alert.rule.query": Object { + "path": "signal.rule.query", + "type": "alias", + }, + "kibana.alert.rule.references": Object { + "path": "signal.rule.references", + "type": "alias", + }, + "kibana.alert.rule.risk_score_mapping.field": Object { + "path": "signal.rule.risk_score_mapping.field", + "type": "alias", + }, + "kibana.alert.rule.risk_score_mapping.operator": Object { + "path": "signal.rule.risk_score_mapping.operator", + "type": "alias", + }, + "kibana.alert.rule.risk_score_mapping.value": Object { + "path": "signal.rule.risk_score_mapping.value", + "type": "alias", + }, + "kibana.alert.rule.rule_id": Object { + "path": "signal.rule.rule_id", + "type": "alias", + }, + "kibana.alert.rule.rule_name_override": Object { + "path": "signal.rule.rule_name_override", + "type": "alias", + }, + "kibana.alert.rule.saved_id": Object { + "path": "signal.rule.saved_id", + "type": "alias", + }, + "kibana.alert.rule.severity_mapping.field": Object { + "path": "signal.rule.severity_mapping.field", + "type": "alias", + }, + "kibana.alert.rule.severity_mapping.operator": Object { + "path": "signal.rule.severity_mapping.operator", + "type": "alias", + }, + "kibana.alert.rule.severity_mapping.severity": Object { + "path": "signal.rule.severity_mapping.severity", + "type": "alias", + }, + "kibana.alert.rule.severity_mapping.value": Object { + "path": "signal.rule.severity_mapping.value", + "type": "alias", + }, + "kibana.alert.rule.tags": Object { + "path": "signal.rule.tags", + "type": "alias", + }, + "kibana.alert.rule.threat.framework": Object { + "path": "signal.rule.threat.framework", + "type": "alias", + }, + "kibana.alert.rule.threat.tactic.id": Object { + "path": "signal.rule.threat.tactic.id", + "type": "alias", + }, + "kibana.alert.rule.threat.tactic.name": Object { + "path": "signal.rule.threat.tactic.name", + "type": "alias", + }, + "kibana.alert.rule.threat.tactic.reference": Object { + "path": "signal.rule.threat.tactic.reference", + "type": "alias", + }, + "kibana.alert.rule.threat.technique.id": Object { + "path": "signal.rule.threat.technique.id", + "type": "alias", + }, + "kibana.alert.rule.threat.technique.name": Object { + "path": "signal.rule.threat.technique.name", + "type": "alias", + }, + "kibana.alert.rule.threat.technique.reference": Object { + "path": "signal.rule.threat.technique.reference", + "type": "alias", + }, + "kibana.alert.rule.threat.technique.subtechnique.id": Object { + "path": "signal.rule.threat.technique.subtechnique.id", + "type": "alias", + }, + "kibana.alert.rule.threat.technique.subtechnique.name": Object { + "path": "signal.rule.threat.technique.subtechnique.name", + "type": "alias", + }, + "kibana.alert.rule.threat.technique.subtechnique.reference": Object { + "path": "signal.rule.threat.technique.subtechnique.reference", + "type": "alias", + }, + "kibana.alert.rule.threat_index": Object { + "path": "signal.rule.threat_index", + "type": "alias", + }, + "kibana.alert.rule.threat_indicator_path": Object { + "path": "signal.rule.threat_indicator_path", + "type": "alias", + }, + "kibana.alert.rule.threat_language": Object { + "path": "signal.rule.threat_language", + "type": "alias", + }, + "kibana.alert.rule.threat_mapping.entries.field": Object { + "path": "signal.rule.threat_mapping.entries.field", + "type": "alias", + }, + "kibana.alert.rule.threat_mapping.entries.type": Object { + "path": "signal.rule.threat_mapping.entries.type", + "type": "alias", + }, + "kibana.alert.rule.threat_mapping.entries.value": Object { + "path": "signal.rule.threat_mapping.entries.value", + "type": "alias", + }, + "kibana.alert.rule.threat_query": Object { + "path": "signal.rule.threat_query", + "type": "alias", + }, + "kibana.alert.rule.threshold.field": Object { + "path": "signal.rule.threshold.field", + "type": "alias", + }, + "kibana.alert.rule.threshold.value": Object { + "path": "signal.rule.threshold.value", + "type": "alias", + }, + "kibana.alert.rule.timeline_id": Object { + "path": "signal.rule.timeline_id", + "type": "alias", + }, + "kibana.alert.rule.timeline_title": Object { + "path": "signal.rule.timeline_title", + "type": "alias", + }, + "kibana.alert.rule.to": Object { + "path": "signal.rule.to", + "type": "alias", + }, + "kibana.alert.rule.type": Object { + "path": "signal.rule.type", + "type": "alias", + }, + "kibana.alert.rule.updated_at": Object { + "path": "signal.rule.updated_at", + "type": "alias", + }, + "kibana.alert.rule.updated_by": Object { + "path": "signal.rule.updated_by", + "type": "alias", + }, + "kibana.alert.rule.uuid": Object { + "path": "signal.rule.id", + "type": "alias", + }, + "kibana.alert.rule.version": Object { + "path": "signal.rule.version", + "type": "alias", + }, + "kibana.alert.severity": Object { + "path": "signal.rule.severity", + "type": "alias", + }, + "kibana.alert.threshold_result.cardinality.field": Object { + "path": "signal.threshold_result.cardinality.field", + "type": "alias", + }, + "kibana.alert.threshold_result.cardinality.value": Object { + "path": "signal.threshold_result.cardinality.value", + "type": "alias", + }, + "kibana.alert.threshold_result.count": Object { + "path": "signal.threshold_result.count", + "type": "alias", + }, + "kibana.alert.threshold_result.from": Object { + "path": "signal.threshold_result.from", + "type": "alias", + }, + "kibana.alert.threshold_result.terms.field": Object { + "path": "signal.threshold_result.terms.field", + "type": "alias", + }, + "kibana.alert.threshold_result.terms.value": Object { + "path": "signal.threshold_result.terms.value", + "type": "alias", + }, + "kibana.alert.workflow_status": Object { + "path": "signal.status", + "type": "alias", + }, + "signal": Object { + "properties": Object { + "_meta": Object { + "properties": Object { + "version": Object { + "type": "long", + }, + }, + "type": "object", + }, + "ancestors": Object { + "properties": Object { + "depth": Object { + "type": "long", + }, + "id": Object { + "type": "keyword", + }, + "index": Object { + "type": "keyword", + }, + "rule": Object { + "type": "keyword", + }, + "type": Object { + "type": "keyword", + }, + }, + }, + "depth": Object { + "type": "integer", + }, + "group": Object { + "properties": Object { + "id": Object { + "type": "keyword", + }, + "index": Object { + "type": "integer", + }, + }, + "type": "object", + }, + "original_event": Object { + "properties": Object { + "reason": Object { + "type": "keyword", + }, + }, + "type": "object", + }, + "reason": Object { + "type": "keyword", + }, + "rule": Object { + "properties": Object { + "author": Object { + "type": "keyword", + }, + "building_block_type": Object { + "type": "keyword", + }, + "license": Object { + "type": "keyword", + }, + "note": Object { + "type": "text", + }, + "risk_score_mapping": Object { + "properties": Object { + "field": Object { + "type": "keyword", + }, + "operator": Object { + "type": "keyword", + }, + "value": Object { + "type": "keyword", + }, + }, + "type": "object", + }, + "rule_name_override": Object { + "type": "keyword", + }, + "severity_mapping": Object { + "properties": Object { + "field": Object { + "type": "keyword", + }, + "operator": Object { + "type": "keyword", + }, + "severity": Object { + "type": "keyword", + }, + "value": Object { + "type": "keyword", + }, + }, + "type": "object", + }, + "threat": Object { + "properties": Object { + "technique": Object { + "properties": Object { + "subtechnique": Object { + "properties": Object { + "id": Object { + "type": "keyword", + }, + "name": Object { + "type": "keyword", + }, + "reference": Object { + "type": "keyword", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "threat_index": Object { + "type": "keyword", + }, + "threat_indicator_path": Object { + "type": "keyword", + }, + "threat_language": Object { + "type": "keyword", + }, + "threat_mapping": Object { + "properties": Object { + "entries": Object { + "properties": Object { + "field": Object { + "type": "keyword", + }, + "type": Object { + "type": "keyword", + }, + "value": Object { + "type": "keyword", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "threat_query": Object { + "type": "keyword", + }, + "threshold": Object { + "properties": Object { + "field": Object { + "type": "keyword", + }, + "value": Object { + "type": "float", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "threshold_result": Object { + "properties": Object { + "cardinality": Object { + "properties": Object { + "field": Object { + "type": "keyword", + }, + "value": Object { + "type": "long", + }, + }, + }, + "count": Object { + "type": "long", + }, + "from": Object { + "type": "date", + }, + "terms": Object { + "properties": Object { + "field": Object { + "type": "keyword", + }, + "value": Object { + "type": "keyword", + }, + }, + }, + }, + }, + }, + "type": "object", + }, + }, + "runtime": Object { + "host.os.name.caseless": Object { + "script": Object { + "source": "if(doc['host.os.name'].size()!=0) emit(doc['host.os.name'].value.toLowerCase());", + }, + "type": "keyword", + }, + }, +} +`; + +exports[`get_signals_template backwards compatibility mappings for version 57 should match snapshot 1`] = ` +Object { + "_meta": Object { + "aliases_version": 1, + "version": 57, + }, +} +`; + exports[`get_signals_template it should match snapshot 1`] = ` Object { "index_patterns": Array [ @@ -1495,6 +2099,11 @@ Object { }, "name": Object { "fields": Object { + "caseless": Object { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword", + }, "text": Object { "norms": false, "type": "text", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts index d65a1ad87b41a..61635fdcef9f0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts @@ -23,11 +23,9 @@ import type { import { DETECTION_ENGINE_INDEX_URL } from '../../../../../common/constants'; import { buildSiemResponse } from '../utils'; import { - createSignalsFieldAliases, getSignalsTemplate, SIGNALS_TEMPLATE_VERSION, - SIGNALS_FIELD_ALIASES_VERSION, - ALIAS_VERSION_FIELD, + createBackwardsCompatibilityMapping, } from './get_signals_template'; import { ensureMigrationCleanupPolicy } from '../../migrations/migration_cleanup'; import signalsPolicy from './signals_policy.json'; @@ -35,7 +33,6 @@ import { templateNeedsUpdate } from './check_template_version'; import { getIndexVersion } from './get_index_version'; import { isOutdated } from '../../migrations/helpers'; import { RuleDataPluginService } from '../../../../../../rule_registry/server'; -import signalExtraFields from './signal_extra_fields.json'; import { ConfigType } from '../../../../config'; import { parseExperimentalConfigValue } from '../../../../../common/experimental_features'; @@ -126,7 +123,7 @@ export const createDetectionIndex = async ( } if (indexExists) { - await addFieldAliasesToIndices({ esClient, index, spaceId }); + await addFieldAliasesToIndices({ esClient, index }); // The internal user is used here because Elasticsearch requires the PUT alias requestor to have 'manage' permissions // for BOTH the index AND alias name. However, through 7.14 admins only needed permissions for .siem-signals (the index) // and not .alerts-security.alerts (the alias). From the security solution perspective, all .siem-signals--* @@ -148,33 +145,17 @@ export const createDetectionIndex = async ( const addFieldAliasesToIndices = async ({ esClient, index, - spaceId, }: { esClient: ElasticsearchClient; index: string; - spaceId: string; }) => { const { body: indexMappings } = await esClient.indices.get({ index }); - // Make sure that all signal fields we add aliases for are guaranteed to exist in the mapping for ALL historical - // signals indices (either by adding them to signalExtraFields or ensuring they exist in the original signals - // mapping) or else this call will fail and not update ANY signals indices - const fieldAliases = createSignalsFieldAliases(); for (const [indexName, mapping] of Object.entries(indexMappings)) { const currentVersion: number | undefined = get(mapping.mappings?._meta, 'version'); - const newMapping = { - properties: { - ...signalExtraFields, - ...fieldAliases, - // ...getRbacRequiredFields(spaceId), - }, - _meta: { - version: currentVersion, - [ALIAS_VERSION_FIELD]: SIGNALS_FIELD_ALIASES_VERSION, - }, - }; + const body = createBackwardsCompatibilityMapping(currentVersion ?? 0); await esClient.indices.putMapping({ index: indexName, - body: newMapping, + body, allow_no_indices: true, } as estypes.IndicesPutMappingRequest); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts index bb67dd1fca6df..70363cba34fce 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getSignalsTemplate } from './get_signals_template'; +import { createBackwardsCompatibilityMapping, getSignalsTemplate } from './get_signals_template'; describe('get_signals_template', () => { test('it should set the lifecycle "name" and "rollover_alias" to be the name of the index passed in', () => { @@ -124,4 +124,14 @@ describe('get_signals_template', () => { ); expect(template).toMatchSnapshot(); }); + + test('backwards compatibility mappings for version 45 should match snapshot', () => { + const mapping = createBackwardsCompatibilityMapping(45); + expect(mapping).toMatchSnapshot(); + }); + + test('backwards compatibility mappings for version 57 should match snapshot', () => { + const mapping = createBackwardsCompatibilityMapping(57); + expect(mapping).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts index 3470f955dbdba..b7a0521e5c3ce 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts @@ -11,10 +11,12 @@ import { ALERT_RULE_PRODUCER, ALERT_RULE_TYPE_ID, } from '@kbn/rule-data-utils'; +import { merge } from 'lodash'; import signalsMapping from './signals_mapping.json'; import ecsMapping from './ecs_mapping.json'; import otherMapping from './other_mappings.json'; import aadFieldConversion from './signal_aad_mapping.json'; +import signalExtraFields from './signal_extra_fields.json'; /** @constant @@ -22,7 +24,9 @@ import aadFieldConversion from './signal_aad_mapping.json'; @description This value represents the template version assumed by app code. If this number is greater than the user's signals index version, the detections UI will attempt to update the signals template and roll over to - a new signals index. + a new signals index. + + Since we create a new index for new versions, this version on an existing index should never change. If making mappings changes in a patch release, this number should be incremented by 1. If making mappings changes in a minor release, this number should be @@ -34,12 +38,24 @@ export const SIGNALS_TEMPLATE_VERSION = 57; @constant @type {number} @description This value represents the version of the field aliases that map the new field names - used for alerts-as-data to the old signal.* field names. If any .siem-signals- indices - have an aliases_version less than this value, the detections UI will call create_index_route and - and go through the index update process. Increment this number if making changes to the field - aliases we use to make signals forwards-compatible. + used for alerts-as-data to the old signal.* field names and any other runtime fields that are added + to .siem-signals indices for compatibility reasons (e.g. host.os.name.caseless). + + This version number can change over time on existing indices as we add backwards compatibility fields. + + If any .siem-signals- indices have an aliases_version less than this value, the detections + UI will call create_index_route and and go through the index update process. Increment this number if + making changes to the field aliases we use to make signals forwards-compatible. */ export const SIGNALS_FIELD_ALIASES_VERSION = 1; + +/** + @constant + @type {number} + @description This value represents the minimum required index version (SIGNALS_TEMPLATE_VERSION) for EQL + rules to write signals correctly. If the write index has a `version` less than this value, the EQL rule + will throw an error on execution. +*/ export const MIN_EQL_RULE_INDEX_VERSION = 2; export const ALIAS_VERSION_FIELD = 'aliases_version'; @@ -68,13 +84,12 @@ export const getSignalsTemplate = (index: string, spaceId: string, aadIndexAlias }, mappings: { dynamic: false, - properties: { - ...ecsMapping.mappings.properties, - ...otherMapping.mappings.properties, - ...fieldAliases, - // ...getRbacRequiredFields(spaceId), - signal: signalsMapping.mappings.properties.signal, - }, + properties: merge( + ecsMapping.mappings.properties, + otherMapping.mappings.properties, + fieldAliases, + signalsMapping.mappings.properties + ), _meta: { version: SIGNALS_TEMPLATE_VERSION, [ALIAS_VERSION_FIELD]: SIGNALS_FIELD_ALIASES_VERSION, @@ -97,6 +112,47 @@ export const createSignalsFieldAliases = () => { return fieldAliases; }; +export const backwardsCompatibilityMappings = [ + { + minVersion: 0, + // Version 45 shipped with 7.14 + maxVersion: 45, + mapping: { + runtime: { + 'host.os.name.caseless': { + type: 'keyword', + script: { + source: + "if(doc['host.os.name'].size()!=0) emit(doc['host.os.name'].value.toLowerCase());", + }, + }, + }, + properties: { + // signalExtraFields contains the field mappings that have been added to the signals indices over time. + // We need to include these here because we can't add an alias for a field that isn't in the mapping, + // and we want to apply the aliases to all old signals indices at the same time. + ...signalExtraFields, + ...createSignalsFieldAliases(), + }, + }, + }, +]; + +export const createBackwardsCompatibilityMapping = (version: number) => { + const mappings = backwardsCompatibilityMappings + .filter((mapping) => version <= mapping.maxVersion && version >= mapping.minVersion) + .map((mapping) => mapping.mapping); + + const meta = { + _meta: { + version, + [ALIAS_VERSION_FIELD]: SIGNALS_FIELD_ALIASES_VERSION, + }, + }; + + return merge({}, ...mappings, meta); +}; + export const getRbacRequiredFields = (spaceId: string) => { return { [SPACE_IDS]: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/other_mappings.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/other_mappings.json index b61ad2e43ac03..5ad8f5238a97d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/other_mappings.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/other_mappings.json @@ -98,6 +98,29 @@ } } }, + "host": { + "properties": { + "os": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + }, + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, "interface": { "properties": { "alias": { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts b/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts index 969315cb3f98d..53225e4ea2ce0 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts @@ -17,6 +17,7 @@ import { getSignalStatus, createSignalsIndex, deleteSignalsIndex } from '../../u // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('query_signals_route and find_alerts_route', () => { describe('validation checks', () => { @@ -61,6 +62,49 @@ export default ({ getService }: FtrProviderContext) => { }); }); + describe('backwards compatibility', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/endpoint/resolver/signals'); + await createSignalsIndex(supertest); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/endpoint/resolver/signals'); + await deleteSignalsIndex(supertest); + }); + + it('should be able to filter old signals on host.os.name.caseless using runtime field', async () => { + const query = { + query: { + bool: { + should: [{ match_phrase: { 'host.os.name.caseless': 'windows' } }], + }, + }, + }; + const { body } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(query) + .expect(200); + expect(body.hits.total.value).to.eql(3); + }); + + it('should be able to filter old signals using field aliases', async () => { + const query = { + query: { + bool: { + should: [{ match_phrase: { 'kibana.alert.workflow_status': 'open' } }], + }, + }, + }; + const { body } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(query) + .expect(200); + expect(body.hits.total.value).to.eql(3); + }); + }); + describe('find_alerts_route', () => { describe('validation checks', () => { it('should not give errors when querying and the signals index does not exist yet', async () => { From 6991f22e97ea8866cee3b47e009484bf5c500b82 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Fri, 10 Sep 2021 17:39:06 +0200 Subject: [PATCH 08/10] Fix link to e2e tests in APM testing.md (#111869) --- x-pack/plugins/apm/dev_docs/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/dev_docs/testing.md b/x-pack/plugins/apm/dev_docs/testing.md index 93f32111048c1..4d0edc27fe644 100644 --- a/x-pack/plugins/apm/dev_docs/testing.md +++ b/x-pack/plugins/apm/dev_docs/testing.md @@ -42,7 +42,7 @@ The API tests are located in `x-pack/test/apm_api_integration/`. node scripts/test/e2e [--trial] [--help] ``` -The E2E tests are located [here](../../ftr_e2e) +The E2E tests are located [here](../ftr_e2e) --- From 7da17a5e67b2443ec1c3f8f2d46cec2a97f45a7a Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Fri, 10 Sep 2021 17:44:27 +0200 Subject: [PATCH 09/10] Make classnames a shared dep (#111636) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-ui-shared-deps/src/entry.js | 1 + packages/kbn-ui-shared-deps/src/index.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/kbn-ui-shared-deps/src/entry.js b/packages/kbn-ui-shared-deps/src/entry.js index 013d5f894c013..7544e6953f3e9 100644 --- a/packages/kbn-ui-shared-deps/src/entry.js +++ b/packages/kbn-ui-shared-deps/src/entry.js @@ -56,3 +56,4 @@ export const KbnStd = require('@kbn/std'); export const SaferLodashSet = require('@elastic/safer-lodash-set'); export const RisonNode = require('rison-node'); export const History = require('history'); +export const Classnames = require('classnames'); diff --git a/packages/kbn-ui-shared-deps/src/index.js b/packages/kbn-ui-shared-deps/src/index.js index 3d3553ba23546..31e5e2c3b1e8e 100644 --- a/packages/kbn-ui-shared-deps/src/index.js +++ b/packages/kbn-ui-shared-deps/src/index.js @@ -101,6 +101,7 @@ exports.externals = { '@elastic/safer-lodash-set': '__kbnSharedDeps__.SaferLodashSet', 'rison-node': '__kbnSharedDeps__.RisonNode', history: '__kbnSharedDeps__.History', + classnames: '__kbnSharedDeps__.Classnames', }; /** From 13560c01fce4e71972082bea17d61ded505e30bf Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Fri, 10 Sep 2021 22:42:24 +0300 Subject: [PATCH 10/10] [Usage collection] refactor cloud detector collector (#110439) --- package.json | 3 - .../collectors/cloud/detector/aws.test.ts | 313 +++++++----------- .../server/collectors/cloud/detector/aws.ts | 99 +++--- .../collectors/cloud/detector/azure.test.ts | 89 ++--- .../server/collectors/cloud/detector/azure.ts | 36 +- .../cloud/detector/cloud_detector.ts | 8 +- .../cloud/detector/cloud_service.test.ts | 89 ++--- .../cloud/detector/cloud_service.ts | 78 ++--- .../collectors/cloud/detector/gcp.test.ts | 199 ++++++----- .../server/collectors/cloud/detector/gcp.ts | 126 ++++--- yarn.lock | 181 ++++++++-- 11 files changed, 653 insertions(+), 568 deletions(-) diff --git a/package.json b/package.json index 3cf8db1e1ca7e..06755da56451a 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,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" @@ -368,7 +367,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", @@ -606,7 +604,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 74118bdf06ce7..0cb3876c0d2ed 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" @@ -19362,6 +19430,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" @@ -19369,6 +19442,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" @@ -20486,6 +20566,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" @@ -22443,7 +22528,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= @@ -22525,6 +22610,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" @@ -24184,7 +24274,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== @@ -25261,6 +25379,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" @@ -26007,6 +26132,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" @@ -27082,6 +27212,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" @@ -28133,7 +28270,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==