diff --git a/x-pack/plugins/ml/common/util/errors.test.ts b/x-pack/plugins/ml/common/util/errors.test.ts new file mode 100644 index 000000000000..00af27248ccc --- /dev/null +++ b/x-pack/plugins/ml/common/util/errors.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + BoomResponse, + extractErrorMessage, + MLCustomHttpResponseOptions, + MLResponseError, +} from './errors'; +import { ResponseError } from 'kibana/server'; + +describe('ML - error message utils', () => { + describe('extractErrorMessage', () => { + test('returns just the error message', () => { + const testMsg = 'Saved object [index-pattern/blahblahblah] not found'; + + const bodyWithNestedErrorMsg: MLCustomHttpResponseOptions = { + body: { + message: { + msg: testMsg, + }, + }, + statusCode: 404, + }; + expect(extractErrorMessage(bodyWithNestedErrorMsg)).toBe(testMsg); + + const bodyWithStringMsg: MLCustomHttpResponseOptions = { + body: { + msg: testMsg, + }, + statusCode: 404, + }; + expect(extractErrorMessage(bodyWithStringMsg)).toBe(testMsg); + + const bodyWithStringMessage: MLCustomHttpResponseOptions = { + body: { + message: testMsg, + }, + statusCode: 404, + }; + expect(extractErrorMessage(bodyWithStringMessage)).toBe(testMsg); + + const bodyWithString: MLCustomHttpResponseOptions = { + body: testMsg, + statusCode: 404, + }; + expect(extractErrorMessage(bodyWithString)).toBe(testMsg); + + const bodyWithError: MLCustomHttpResponseOptions = { + body: new Error(testMsg), + statusCode: 404, + }; + expect(extractErrorMessage(bodyWithError)).toBe(testMsg); + + const bodyWithBoomError: MLCustomHttpResponseOptions = { + statusCode: 404, + body: { + data: [], + isBoom: true, + isServer: false, + output: { + statusCode: 404, + payload: { + statusCode: 404, + error: testMsg, + message: testMsg, + }, + headers: {}, + }, + }, + }; + expect(extractErrorMessage(bodyWithBoomError)).toBe(testMsg); + }); + }); +}); diff --git a/x-pack/plugins/ml/common/util/errors.ts b/x-pack/plugins/ml/common/util/errors.ts index 4446624bf2e7..e165e15d7c64 100644 --- a/x-pack/plugins/ml/common/util/errors.ts +++ b/x-pack/plugins/ml/common/util/errors.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ResponseError, ResponseHeaders } from 'kibana/server'; import { isErrorResponse } from '../types/errors'; export function getErrorMessage(error: any) { @@ -17,3 +18,77 @@ export function getErrorMessage(error: any) { return JSON.stringify(error); } + +// Adding temporary types until Kibana ResponseError is updated + +export interface BoomResponse { + data: any; + isBoom: boolean; + isServer: boolean; + output: { + statusCode: number; + payload: { + statusCode: number; + error: string; + message: string; + }; + headers: {}; + }; +} +export type MLResponseError = + | { + message: { + msg: string; + }; + } + | { msg: string }; + +export interface MLCustomHttpResponseOptions< + T extends ResponseError | MLResponseError | BoomResponse +> { + /** HTTP message to send to the client */ + body?: T; + /** HTTP Headers with additional information about response */ + headers?: ResponseHeaders; + statusCode: number; +} + +export const extractErrorMessage = ( + error: + | MLCustomHttpResponseOptions + | undefined + | string +): string => { + // extract only the error message within the response error coming from Kibana, Elasticsearch, and our own ML messages + + if (typeof error === 'string') { + return error; + } + if (error?.body === undefined) return ''; + + if (typeof error.body === 'string') { + return error.body; + } + if ( + typeof error.body === 'object' && + 'output' in error.body && + error.body.output.payload.message + ) { + return error.body.output.payload.message; + } + + if (typeof error.body === 'object' && 'msg' in error.body && typeof error.body.msg === 'string') { + return error.body.msg; + } + + if (typeof error.body === 'object' && 'message' in error.body) { + if (typeof error.body.message === 'string') { + return error.body.message; + } + if (!(error.body.message instanceof Error) && typeof (error.body.message.msg === 'string')) { + return error.body.message.msg; + } + } + // If all else fail return an empty message instead of JSON.stringify + return ''; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx index a1f0448b819d..e8b1cd1a5696 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx @@ -21,7 +21,7 @@ import { import { State } from '../../hooks/use_create_analytics_form/state'; import { DataFrameAnalyticsListRow } from './common'; import { checkPermission } from '../../../../../capabilities/check_capabilities'; -import { extractErrorMessage } from '../../../../../util/error_utils'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; interface PropDefinition { /** diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx index 2d433f6b1848..38ef00914e8f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx @@ -18,6 +18,7 @@ import { } from '@elastic/eui'; import { IIndexPattern } from 'src/plugins/data/common'; import { FormattedMessage } from '@kbn/i18n/react'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { deleteAnalytics, deleteAnalyticsAndDestIndex, @@ -29,7 +30,6 @@ import { } from '../../../../../capabilities/check_capabilities'; import { useMlKibana } from '../../../../../contexts/kibana'; import { isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; -import { extractErrorMessage } from '../../../../../util/error_utils'; interface DeleteActionProps { item: DataFrameAnalyticsListRow; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts index 26cefff0a3f5..ebd3fa898260 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { getToastNotifications } from '../../../../../util/dependency_cache'; import { ml } from '../../../../../services/ml_api_service'; import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common'; @@ -11,7 +12,6 @@ import { isDataFrameAnalyticsFailed, DataFrameAnalyticsListRow, } from '../../components/analytics_list/common'; -import { extractErrorMessage } from '../../../../../util/error_utils'; export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => { const toastNotifications = getToastNotifications(); diff --git a/x-pack/plugins/ml/public/application/util/error_utils.ts b/x-pack/plugins/ml/public/application/util/error_utils.ts deleted file mode 100644 index 2ce8f4ffc583..000000000000 --- a/x-pack/plugins/ml/public/application/util/error_utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; - -export const extractErrorMessage = ( - error: CustomHttpResponseOptions | undefined | string -): string | undefined => { - if (typeof error === 'string') { - return error; - } - - if (error?.body) { - if (typeof error.body === 'string') { - return error.body; - } - if (typeof error.body === 'object' && 'message' in error.body) { - if (typeof error.body.message === 'string') { - return error.body.message; - } - // @ts-ignore - if (typeof (error.body.message?.msg === 'string')) { - // @ts-ignore - return error.body.message?.msg; - } - } - } - return undefined; -}; diff --git a/x-pack/plugins/ml/public/shared.ts b/x-pack/plugins/ml/public/shared.ts index 6821cb7ef0f9..ff83d79adff6 100644 --- a/x-pack/plugins/ml/public/shared.ts +++ b/x-pack/plugins/ml/public/shared.ts @@ -14,6 +14,7 @@ export * from '../common/types/audit_message'; export * from '../common/util/anomaly_utils'; export * from '../common/util/errors'; + export * from '../common/util/validators'; export * from './application/formatters/metric_change_description'; diff --git a/x-pack/plugins/transform/common/index.ts b/x-pack/plugins/transform/common/index.ts index d7a791e78b3a..79ff6298a2ca 100644 --- a/x-pack/plugins/transform/common/index.ts +++ b/x-pack/plugins/transform/common/index.ts @@ -38,3 +38,20 @@ export interface ResultData { export interface TransformEndpointResult { [key: string]: ResultData; } + +export interface DeleteTransformEndpointRequest { + transformsInfo: TransformEndpointRequest[]; + deleteDestIndex?: boolean; + deleteDestIndexPattern?: boolean; +} + +export interface DeleteTransformStatus { + transformDeleted: ResultData; + destIndexDeleted?: ResultData; + destIndexPatternDeleted?: ResultData; + destinationIndex?: string | undefined; +} + +export interface DeleteTransformEndpointResult { + [key: string]: DeleteTransformStatus; +} diff --git a/x-pack/plugins/transform/public/app/components/toast_notification_text.tsx b/x-pack/plugins/transform/public/app/components/toast_notification_text.tsx index 104408167052..3f664cf8bb09 100644 --- a/x-pack/plugins/transform/public/app/components/toast_notification_text.tsx +++ b/x-pack/plugins/transform/public/app/components/toast_notification_text.tsx @@ -29,9 +29,14 @@ const MAX_SIMPLE_MESSAGE_LENGTH = 140; interface ToastNotificationTextProps { overlays: CoreStart['overlays']; text: any; + previewTextLength?: number; } -export const ToastNotificationText: FC = ({ overlays, text }) => { +export const ToastNotificationText: FC = ({ + overlays, + text, + previewTextLength, +}) => { if (typeof text === 'string' && text.length <= MAX_SIMPLE_MESSAGE_LENGTH) { return text; } @@ -46,8 +51,9 @@ export const ToastNotificationText: FC = ({ overlays const unformattedText = text.message ? text.message : text; const formattedText = typeof unformattedText === 'object' ? JSON.stringify(text, null, 2) : text; - const previewText = `${formattedText.substring(0, 140)}${ - formattedText.length > 140 ? ' ...' : '' + const textLength = previewTextLength ?? 140; + const previewText = `${formattedText.substring(0, textLength)}${ + formattedText.length > textLength ? ' ...' : '' }`; const openModal = () => { diff --git a/x-pack/plugins/transform/public/app/hooks/index.ts b/x-pack/plugins/transform/public/app/hooks/index.ts index a36550bcd8e5..b439afe2b216 100644 --- a/x-pack/plugins/transform/public/app/hooks/index.ts +++ b/x-pack/plugins/transform/public/app/hooks/index.ts @@ -6,7 +6,7 @@ export { useApi } from './use_api'; export { useGetTransforms } from './use_get_transforms'; -export { useDeleteTransforms } from './use_delete_transform'; +export { useDeleteTransforms, useDeleteIndexAndTargetIndex } from './use_delete_transform'; export { useStartTransforms } from './use_start_transform'; export { useStopTransforms } from './use_stop_transform'; export { useRequest } from './use_request'; diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index f3c35d358f1f..5d7839cf5fba 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -5,7 +5,12 @@ */ import { useMemo } from 'react'; -import { TransformEndpointRequest, TransformEndpointResult, TransformId } from '../../../common'; +import { + TransformId, + TransformEndpointRequest, + TransformEndpointResult, + DeleteTransformEndpointResult, +} from '../../../common'; import { API_BASE_PATH } from '../../../common/constants'; import { useAppDependencies } from '../app_dependencies'; @@ -40,10 +45,12 @@ export const useApi = () => { }); }, deleteTransforms( - transformsInfo: TransformEndpointRequest[] - ): Promise { + transformsInfo: TransformEndpointRequest[], + deleteDestIndex: boolean | undefined, + deleteDestIndexPattern: boolean | undefined + ): Promise { return http.post(`${API_BASE_PATH}delete_transforms`, { - body: JSON.stringify(transformsInfo), + body: JSON.stringify({ transformsInfo, deleteDestIndex, deleteDestIndexPattern }), }); }, getTransformsPreview(obj: PreviewRequestBody): Promise { diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index 0215d723188b..1f395e67b7d3 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -4,52 +4,257 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; - +import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; - -import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; - -import { getErrorMessage } from '../../shared_imports'; - +import { + TransformEndpointRequest, + DeleteTransformEndpointResult, + DeleteTransformStatus, +} from '../../../common'; +import { getErrorMessage, extractErrorMessage } from '../../shared_imports'; import { useAppDependencies, useToastNotifications } from '../app_dependencies'; import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; import { ToastNotificationText } from '../components'; - import { useApi } from './use_api'; +import { indexService } from '../services/es_index_service'; + +export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { + const { http, savedObjects } = useAppDependencies(); + const toastNotifications = useToastNotifications(); + + const [deleteDestIndex, setDeleteDestIndex] = useState(true); + const [deleteIndexPattern, setDeleteIndexPattern] = useState(true); + const [userCanDeleteIndex, setUserCanDeleteIndex] = useState(false); + const [indexPatternExists, setIndexPatternExists] = useState(false); + const toggleDeleteIndex = useCallback(() => setDeleteDestIndex(!deleteDestIndex), [ + deleteDestIndex, + ]); + const toggleDeleteIndexPattern = useCallback(() => setDeleteIndexPattern(!deleteIndexPattern), [ + deleteIndexPattern, + ]); + + const checkIndexPatternExists = useCallback( + async (indexName: string) => { + try { + if (await indexService.indexPatternExists(savedObjects.client, indexName)) { + setIndexPatternExists(true); + } + } catch (e) { + const error = extractErrorMessage(e); + + toastNotifications.addDanger( + i18n.translate( + 'xpack.transform.deleteTransform.errorWithCheckingIfIndexPatternExistsNotificationErrorMessage', + { + defaultMessage: + 'An error occurred checking if index pattern {indexPattern} exists: {error}', + values: { indexPattern: indexName, error }, + } + ) + ); + } + }, + [savedObjects.client, toastNotifications] + ); + + const checkUserIndexPermission = useCallback(async () => { + try { + const userCanDelete = await indexService.canDeleteIndex(http); + if (userCanDelete) { + setUserCanDeleteIndex(true); + } + } catch (e) { + toastNotifications.addDanger( + i18n.translate( + 'xpack.transform.transformList.errorWithCheckingIfUserCanDeleteIndexNotificationErrorMessage', + { + defaultMessage: 'An error occurred checking if user can delete destination index', + } + ) + ); + } + }, [http, toastNotifications]); + + useEffect(() => { + checkUserIndexPermission(); + + if (items.length === 1) { + const config = items[0].config; + const destinationIndex = Array.isArray(config.dest.index) + ? config.dest.index[0] + : config.dest.index; + checkIndexPatternExists(destinationIndex); + } else { + setIndexPatternExists(true); + } + }, [checkIndexPatternExists, checkUserIndexPermission, items]); + + return { + userCanDeleteIndex, + deleteDestIndex, + indexPatternExists, + deleteIndexPattern, + toggleDeleteIndex, + toggleDeleteIndexPattern, + }; +}; + +type SuccessCountField = keyof Omit; export const useDeleteTransforms = () => { const { overlays } = useAppDependencies(); const toastNotifications = useToastNotifications(); const api = useApi(); - return async (transforms: TransformListRow[]) => { + return async ( + transforms: TransformListRow[], + shouldDeleteDestIndex: boolean, + shouldDeleteDestIndexPattern: boolean + ) => { const transformsInfo: TransformEndpointRequest[] = transforms.map((tf) => ({ id: tf.config.id, state: tf.stats.state, })); try { - const results: TransformEndpointResult = await api.deleteTransforms(transformsInfo); + const results: DeleteTransformEndpointResult = await api.deleteTransforms( + transformsInfo, + shouldDeleteDestIndex, + shouldDeleteDestIndexPattern + ); + const isBulk = Object.keys(results).length > 1; + const successCount: Record = { + transformDeleted: 0, + destIndexDeleted: 0, + destIndexPatternDeleted: 0, + }; for (const transformId in results) { // hasOwnProperty check to ensure only properties on object itself, and not its prototypes if (results.hasOwnProperty(transformId)) { - if (results[transformId].success === true) { - toastNotifications.addSuccess( - i18n.translate('xpack.transform.transformList.deleteTransformSuccessMessage', { - defaultMessage: 'Request to delete transform {transformId} acknowledged.', - values: { transformId }, - }) - ); + const status = results[transformId]; + const destinationIndex = status.destinationIndex; + + // if we are only deleting one transform, show the success toast messages + if (!isBulk && status.transformDeleted) { + if (status.transformDeleted?.success) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.deleteTransformSuccessMessage', { + defaultMessage: 'Request to delete transform {transformId} acknowledged.', + values: { transformId }, + }) + ); + } + if (status.destIndexDeleted?.success) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage', + { + defaultMessage: + 'Request to delete destination index {destinationIndex} acknowledged.', + values: { destinationIndex }, + } + ) + ); + } + if (status.destIndexPatternDeleted?.success) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternSuccessMessage', + { + defaultMessage: + 'Request to delete index pattern {destinationIndex} acknowledged.', + values: { destinationIndex }, + } + ) + ); + } } else { - toastNotifications.addDanger( - i18n.translate('xpack.transform.transformList.deleteTransformErrorMessage', { + (Object.keys(successCount) as SuccessCountField[]).forEach((key) => { + if (status[key]?.success) { + successCount[key] = successCount[key] + 1; + } + }); + } + if (status.transformDeleted?.error) { + const error = extractErrorMessage(status.transformDeleted.error); + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.transformList.deleteTransformErrorMessage', { defaultMessage: 'An error occurred deleting the transform {transformId}', values: { transformId }, - }) - ); + }), + text: toMountPoint( + + ), + }); } + + if (status.destIndexDeleted?.error) { + const error = extractErrorMessage(status.destIndexDeleted.error); + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage', + { + defaultMessage: 'An error occurred deleting destination index {destinationIndex}', + values: { destinationIndex }, + } + ), + text: toMountPoint( + + ), + }); + } + + if (status.destIndexPatternDeleted?.error) { + const error = extractErrorMessage(status.destIndexPatternDeleted.error); + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternErrorMessage', + { + defaultMessage: 'An error occurred deleting index pattern {destinationIndex}', + values: { destinationIndex }, + } + ), + text: toMountPoint( + + ), + }); + } + } + } + + // if we are deleting multiple transforms, combine the success messages + if (isBulk) { + if (successCount.transformDeleted > 0) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.bulkDeleteTransformSuccessMessage', { + defaultMessage: + 'Successfully deleted {count} {count, plural, one {transform} other {transforms}}.', + values: { count: successCount.transformDeleted }, + }) + ); + } + + if (successCount.destIndexDeleted > 0) { + toastNotifications.addSuccess( + i18n.translate('xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage', { + defaultMessage: + 'Successfully deleted {count} destination {count, plural, one {index} other {indices}}.', + values: { count: successCount.destIndexDeleted }, + }) + ); + } + if (successCount.destIndexPatternDeleted > 0) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.transform.transformList.bulkDeleteDestIndexPatternSuccessMessage', + { + defaultMessage: + 'Successfully deleted {count} destination index {count, plural, one {pattern} other {patterns}}.', + values: { count: successCount.destIndexPatternDeleted }, + } + ) + ); } } @@ -59,7 +264,13 @@ export const useDeleteTransforms = () => { title: i18n.translate('xpack.transform.transformList.deleteTransformGenericErrorMessage', { defaultMessage: 'An error occurred calling the API endpoint to delete transforms.', }), - text: toMountPoint(), + text: toMountPoint( + + ), }); } }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx index c20feba29f58..d7db55990d33 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx @@ -12,11 +12,14 @@ import { EuiOverlayMask, EuiToolTip, EUI_MODAL_CONFIRM_BUTTON, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSpacer, } from '@elastic/eui'; - +import { FormattedMessage } from '@kbn/i18n/react'; import { TRANSFORM_STATE } from '../../../../../../common'; - -import { useDeleteTransforms } from '../../../../hooks'; +import { useDeleteTransforms, useDeleteIndexAndTargetIndex } from '../../../../hooks'; import { createCapabilityFailureMessage, AuthorizationContext, @@ -35,13 +38,25 @@ export const DeleteAction: FC = ({ items, forceDisable }) => const { canDeleteTransform } = useContext(AuthorizationContext).capabilities; const deleteTransforms = useDeleteTransforms(); + const { + userCanDeleteIndex, + deleteDestIndex, + indexPatternExists, + deleteIndexPattern, + toggleDeleteIndex, + toggleDeleteIndexPattern, + } = useDeleteIndexAndTargetIndex(items); const [isModalVisible, setModalVisible] = useState(false); const closeModal = () => setModalVisible(false); const deleteAndCloseModal = () => { setModalVisible(false); - deleteTransforms(items); + + const shouldDeleteDestIndex = userCanDeleteIndex && deleteDestIndex; + const shouldDeleteDestIndexPattern = + userCanDeleteIndex && indexPatternExists && deleteIndexPattern; + deleteTransforms(items, shouldDeleteDestIndex, shouldDeleteDestIndexPattern); }; const openModal = () => setModalVisible(true); @@ -71,17 +86,96 @@ export const DeleteAction: FC = ({ items, forceDisable }) => defaultMessage: 'Delete {transformId}', values: { transformId: items[0] && items[0].config.id }, }); - const bulkDeleteModalMessage = i18n.translate( - 'xpack.transform.transformList.bulkDeleteModalBody', - { - defaultMessage: - "Are you sure you want to delete {count, plural, one {this} other {these}} {count} {count, plural, one {transform} other {transforms}}? The transform's destination index and optional Kibana index pattern will not be deleted.", - values: { count: items.length }, - } + const bulkDeleteModalContent = ( + <> +

+ +

+ + + { + + } + + + + { + + } + + + + ); + + const deleteModalContent = ( + <> +

+ +

+ + + {userCanDeleteIndex && ( + + )} + + {userCanDeleteIndex && indexPatternExists && ( + + + + + )} + + ); - const deleteModalMessage = i18n.translate('xpack.transform.transformList.deleteModalBody', { - defaultMessage: `Are you sure you want to delete this transform? The transform's destination index and optional Kibana index pattern will not be deleted.`, - }); let deleteButton = ( = ({ items, forceDisable }) => if (disabled || !canDeleteTransform) { let content; if (disabled) { - content = isBulkAction === true ? bulkDeleteButtonDisabledText : deleteButtonDisabledText; + content = isBulkAction ? bulkDeleteButtonDisabledText : deleteButtonDisabledText; } else { content = createCapabilityFailureMessage('canDeleteTransform'); } @@ -117,7 +211,7 @@ export const DeleteAction: FC = ({ items, forceDisable }) => {isModalVisible && ( = ({ items, forceDisable }) => defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} buttonColor="danger" > -

{isBulkAction === true ? bulkDeleteModalMessage : deleteModalMessage}

+ {isBulkAction ? bulkDeleteModalContent : deleteModalContent}
)} diff --git a/x-pack/plugins/transform/public/app/services/es_index_service.ts b/x-pack/plugins/transform/public/app/services/es_index_service.ts new file mode 100644 index 000000000000..491213d0ddbe --- /dev/null +++ b/x-pack/plugins/transform/public/app/services/es_index_service.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup, SavedObjectsClientContract } from 'kibana/public'; +import { API_BASE_PATH } from '../../../common/constants'; +import { IIndexPattern } from '../../../../../../src/plugins/data/common/index_patterns'; + +export class IndexService { + async canDeleteIndex(http: HttpSetup) { + const privilege = await http.get(`${API_BASE_PATH}privileges`); + if (!privilege) { + return false; + } + return privilege.hasAllPrivileges; + } + + async indexPatternExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { + const response = await savedObjectsClient.find({ + type: 'index-pattern', + perPage: 1, + search: `"${indexName}"`, + searchFields: ['title'], + fields: ['title'], + }); + const ip = response.savedObjects.find((obj) => obj.attributes.title === indexName); + return ip !== undefined; + } +} + +export const indexService = new IndexService(); diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index 56be8d7bb7de..ca3fb52cc02c 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -15,6 +15,7 @@ export { export { getErrorMessage, + extractErrorMessage, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, multiColumnSortFactory, diff --git a/x-pack/plugins/transform/server/routes/api/error_utils.ts b/x-pack/plugins/transform/server/routes/api/error_utils.ts index 295375794c04..5a479e4f429f 100644 --- a/x-pack/plugins/transform/server/routes/api/error_utils.ts +++ b/x-pack/plugins/transform/server/routes/api/error_utils.ts @@ -10,7 +10,11 @@ import { i18n } from '@kbn/i18n'; import { ResponseError, CustomHttpResponseOptions } from 'src/core/server'; -import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; +import { + TransformEndpointRequest, + TransformEndpointResult, + DeleteTransformEndpointResult, +} from '../../../common'; const REQUEST_TIMEOUT = 'RequestTimeout'; @@ -19,7 +23,7 @@ export function isRequestTimeout(error: any) { } interface Params { - results: TransformEndpointResult; + results: TransformEndpointResult | DeleteTransformEndpointResult; id: string; items: TransformEndpointRequest[]; action: string; @@ -59,7 +63,7 @@ export function fillResultsWithTimeouts({ results, id, items, action }: Params) }, }; - const newResults: TransformEndpointResult = {}; + const newResults: TransformEndpointResult | DeleteTransformEndpointResult = {}; return items.reduce((accumResults, currentVal) => { if (results[currentVal.id] === undefined) { diff --git a/x-pack/plugins/transform/server/routes/api/schema.ts b/x-pack/plugins/transform/server/routes/api/schema.ts index 0b994406d324..cf39f2e3829e 100644 --- a/x-pack/plugins/transform/server/routes/api/schema.ts +++ b/x-pack/plugins/transform/server/routes/api/schema.ts @@ -14,3 +14,17 @@ export const schemaTransformId = { export interface SchemaTransformId { transformId: string; } + +export const deleteTransformSchema = schema.object({ + /** + * Delete Transform & Destination Index + */ + transformsInfo: schema.arrayOf( + schema.object({ + id: schema.string(), + state: schema.maybe(schema.string()), + }) + ), + deleteDestIndex: schema.maybe(schema.boolean()), + deleteDestIndexPattern: schema.maybe(schema.boolean()), +}); diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 55b2469a7f3a..93fda56d319a 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -5,7 +5,12 @@ */ import { schema } from '@kbn/config-schema'; -import { RequestHandler } from 'kibana/server'; +import { + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + SavedObjectsClientContract, +} from 'kibana/server'; import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; @@ -14,6 +19,9 @@ import { TransformEndpointResult, TransformId, TRANSFORM_STATE, + DeleteTransformEndpointRequest, + DeleteTransformStatus, + ResultData, } from '../../../common'; import { RouteDependencies } from '../../types'; @@ -21,8 +29,9 @@ import { RouteDependencies } from '../../types'; import { addBasePath } from '../index'; import { isRequestTimeout, fillResultsWithTimeouts, wrapError } from './error_utils'; -import { schemaTransformId, SchemaTransformId } from './schema'; +import { deleteTransformSchema, schemaTransformId, SchemaTransformId } from './schema'; import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; +import { IIndexPattern } from '../../../../../../src/plugins/data/common/index_patterns'; enum TRANSFORM_ACTIONS { STOP = 'stop', @@ -173,15 +182,37 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { { path: addBasePath('delete_transforms'), validate: { - body: schema.maybe(schema.any()), + body: deleteTransformSchema, }, }, license.guardApiRoute(async (ctx, req, res) => { - const transformsInfo = req.body as TransformEndpointRequest[]; + const { + transformsInfo, + deleteDestIndex, + deleteDestIndexPattern, + } = req.body as DeleteTransformEndpointRequest; try { + const body = await deleteTransforms( + transformsInfo, + deleteDestIndex, + deleteDestIndexPattern, + ctx, + license, + res + ); + + if (body && body.status) { + if (body.status === 404) { + return res.notFound(); + } + if (body.status === 403) { + return res.forbidden(); + } + } + return res.ok({ - body: await deleteTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + body, }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); @@ -238,18 +269,51 @@ const getTransforms = async (options: { transformId?: string }, callAsCurrentUse return await callAsCurrentUser('transform.getTransforms', options); }; +async function getIndexPatternId( + indexName: string, + savedObjectsClient: SavedObjectsClientContract +) { + const response = await savedObjectsClient.find({ + type: 'index-pattern', + perPage: 1, + search: `"${indexName}"`, + searchFields: ['title'], + fields: ['title'], + }); + const ip = response.saved_objects.find((obj) => obj.attributes.title === indexName); + return ip?.id; +} + +async function deleteDestIndexPatternById( + indexPatternId: string, + savedObjectsClient: SavedObjectsClientContract +) { + return await savedObjectsClient.delete('index-pattern', indexPatternId); +} + async function deleteTransforms( transformsInfo: TransformEndpointRequest[], - callAsCurrentUser: CallCluster + deleteDestIndex: boolean | undefined, + deleteDestIndexPattern: boolean | undefined, + ctx: RequestHandlerContext, + license: RouteDependencies['license'], + response: KibanaResponseFactory ) { - const results: TransformEndpointResult = {}; + const tempResults: TransformEndpointResult = {}; + const results: Record = {}; for (const transformInfo of transformsInfo) { + let destinationIndex: string | undefined; + const transformDeleted: ResultData = { success: false }; + const destIndexDeleted: ResultData = { success: false }; + const destIndexPatternDeleted: ResultData = { + success: false, + }; const transformId = transformInfo.id; try { if (transformInfo.state === TRANSFORM_STATE.FAILED) { try { - await callAsCurrentUser('transform.stopTransform', { + await ctx.transform!.dataClient.callAsCurrentUser('transform.stopTransform', { transformId, force: true, waitForCompletion: true, @@ -257,7 +321,7 @@ async function deleteTransforms( } catch (e) { if (isRequestTimeout(e)) { return fillResultsWithTimeouts({ - results, + results: tempResults, id: transformId, items: transformsInfo, action: TRANSFORM_ACTIONS.DELETE, @@ -265,9 +329,75 @@ async function deleteTransforms( } } } + // Grab destination index info to delete + try { + const transformConfigs = await getTransforms( + { transformId }, + ctx.transform!.dataClient.callAsCurrentUser + ); + const transformConfig = transformConfigs.transforms[0]; + destinationIndex = Array.isArray(transformConfig.dest.index) + ? transformConfig.dest.index[0] + : transformConfig.dest.index; + } catch (getTransformConfigError) { + transformDeleted.error = wrapError(getTransformConfigError); + results[transformId] = { + transformDeleted, + destIndexDeleted, + destIndexPatternDeleted, + destinationIndex, + }; + continue; + } - await callAsCurrentUser('transform.deleteTransform', { transformId }); - results[transformId] = { success: true }; + // If user checks box to delete the destinationIndex associated with the job + if (destinationIndex && deleteDestIndex) { + try { + // If user does have privilege to delete the index, then delete the index + // if no permission then return 403 forbidden + await ctx.transform!.dataClient.callAsCurrentUser('indices.delete', { + index: destinationIndex, + }); + destIndexDeleted.success = true; + } catch (deleteIndexError) { + destIndexDeleted.error = wrapError(deleteIndexError); + } + } + + // Delete the index pattern if there's an index pattern that matches the name of dest index + if (destinationIndex && deleteDestIndexPattern) { + try { + const indexPatternId = await getIndexPatternId( + destinationIndex, + ctx.core.savedObjects.client + ); + if (indexPatternId) { + await deleteDestIndexPatternById(indexPatternId, ctx.core.savedObjects.client); + destIndexPatternDeleted.success = true; + } + } catch (deleteDestIndexPatternError) { + destIndexPatternDeleted.error = wrapError(deleteDestIndexPatternError); + } + } + + try { + await ctx.transform!.dataClient.callAsCurrentUser('transform.deleteTransform', { + transformId, + }); + transformDeleted.success = true; + } catch (deleteTransformJobError) { + transformDeleted.error = wrapError(deleteTransformJobError); + if (transformDeleted.error.statusCode === 403) { + return response.forbidden(); + } + } + + results[transformId] = { + transformDeleted, + destIndexDeleted, + destIndexPatternDeleted, + destinationIndex, + }; } catch (e) { if (isRequestTimeout(e)) { return fillResultsWithTimeouts({ @@ -277,7 +407,7 @@ async function deleteTransforms( action: TRANSFORM_ACTIONS.DELETE, }); } - results[transformId] = { success: false, error: JSON.stringify(e) }; + results[transformId] = { transformDeleted: { success: false, error: JSON.stringify(e) } }; } } return results; diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 2719486d0c50..b79dc3f3ffe5 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -30,5 +30,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./ingest_manager')); loadTestFile(require.resolve('./endpoint')); loadTestFile(require.resolve('./ml')); + loadTestFile(require.resolve('./transform')); }); } diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts index 23bff0d0c285..dc0ccfdc53a1 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts @@ -197,7 +197,7 @@ export default ({ getService }: FtrProviderContext) => { await ml.testResources.deleteIndexPattern(destinationIndex); }); - it('deletes job, target index, and index pattern by id', async () => { + it('should delete job, target index, and index pattern by id', async () => { const { body } = await supertest .delete(`/api/ml/data_frame/analytics/${analyticsId}`) .query({ deleteDestIndex: true, deleteDestIndexPattern: true }) diff --git a/x-pack/test/api_integration/apis/transform/delete_transforms.ts b/x-pack/test/api_integration/apis/transform/delete_transforms.ts new file mode 100644 index 000000000000..40300c981ee2 --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/delete_transforms.ts @@ -0,0 +1,318 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { TransformEndpointRequest } from '../../../../plugins/transform/common'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common'; +import { USER } from '../../../functional/services/transform/security_common'; + +async function asyncForEach(array: any[], callback: Function) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array); + } +} + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const transform = getService('transform'); + + function generateDestIndex(transformId: string): string { + return `user-${transformId}`; + } + + async function createTransform(transformId: string, destinationIndex: string) { + const config = { + id: transformId, + source: { index: ['farequote-*'] }, + pivot: { + group_by: { airline: { terms: { field: 'airline' } } }, + aggregations: { '@timestamp.value_count': { value_count: { field: '@timestamp' } } }, + }, + dest: { index: destinationIndex }, + }; + + await transform.api.createTransform(config); + } + + describe('delete_transforms', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await transform.testResources.setKibanaTimeZoneToUTC(); + }); + + after(async () => { + await transform.api.cleanTransformIndices(); + }); + + describe('single transform deletion', function () { + const transformId = 'test1'; + const destinationIndex = generateDestIndex(transformId); + + beforeEach(async () => { + await createTransform(transformId, destinationIndex); + await transform.api.createIndices(destinationIndex); + }); + + afterEach(async () => { + await transform.api.deleteIndices(destinationIndex); + }); + + it('should delete transform by transformId', async () => { + const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const { body } = await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo, + }) + .expect(200); + + expect(body[transformId].transformDeleted.success).to.eql(true); + expect(body[transformId].destIndexDeleted.success).to.eql(false); + expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + await transform.api.waitForTransformNotToExist(transformId); + await transform.api.waitForIndicesToExist(destinationIndex); + }); + + it('should return 403 for unauthorized user', async () => { + const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_VIEWER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_VIEWER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo, + }) + .expect(403); + await transform.api.waitForTransformToExist(transformId); + await transform.api.waitForIndicesToExist(destinationIndex); + }); + }); + + describe('single transform deletion with invalid transformId', function () { + it('should return 200 with error in response if invalid transformId', async () => { + const transformsInfo: TransformEndpointRequest[] = [{ id: 'invalid_transform_id' }]; + const { body } = await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo, + }) + .expect(200); + expect(body.invalid_transform_id.transformDeleted.success).to.eql(false); + expect(body.invalid_transform_id.transformDeleted).to.have.property('error'); + }); + }); + + describe('bulk deletion', function () { + const transformsInfo: TransformEndpointRequest[] = [ + { id: 'bulk_delete_test_1' }, + { id: 'bulk_delete_test_2' }, + ]; + const destinationIndices = transformsInfo.map((d) => generateDestIndex(d.id)); + + beforeEach(async () => { + await asyncForEach(transformsInfo, async ({ id }: { id: string }, idx: number) => { + await createTransform(id, destinationIndices[idx]); + await transform.api.createIndices(destinationIndices[idx]); + }); + }); + + afterEach(async () => { + await asyncForEach(destinationIndices, async (destinationIndex: string) => { + await transform.api.deleteIndices(destinationIndex); + }); + }); + + it('should delete multiple transforms by transformIds', async () => { + const { body } = await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo, + }) + .expect(200); + + await asyncForEach( + transformsInfo, + async ({ id: transformId }: { id: string }, idx: number) => { + expect(body[transformId].transformDeleted.success).to.eql(true); + expect(body[transformId].destIndexDeleted.success).to.eql(false); + expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + await transform.api.waitForTransformNotToExist(transformId); + await transform.api.waitForIndicesToExist(destinationIndices[idx]); + } + ); + }); + + it('should delete multiple transforms by transformIds, even if one of the transformIds is invalid', async () => { + const invalidTransformId = 'invalid_transform_id'; + const { body } = await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo: [ + { id: transformsInfo[0].id }, + { id: invalidTransformId }, + { id: transformsInfo[1].id }, + ], + }) + .expect(200); + + await asyncForEach( + transformsInfo, + async ({ id: transformId }: { id: string }, idx: number) => { + expect(body[transformId].transformDeleted.success).to.eql(true); + expect(body[transformId].destIndexDeleted.success).to.eql(false); + expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + await transform.api.waitForTransformNotToExist(transformId); + await transform.api.waitForIndicesToExist(destinationIndices[idx]); + } + ); + + expect(body[invalidTransformId].transformDeleted.success).to.eql(false); + expect(body[invalidTransformId].transformDeleted).to.have.property('error'); + }); + }); + + describe('with deleteDestIndex setting', function () { + const transformId = 'test2'; + const destinationIndex = generateDestIndex(transformId); + + before(async () => { + await createTransform(transformId, destinationIndex); + await transform.api.createIndices(destinationIndex); + }); + + after(async () => { + await transform.api.deleteIndices(destinationIndex); + }); + + it('should delete transform and destination index', async () => { + const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const { body } = await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo, + deleteDestIndex: true, + }) + .expect(200); + + expect(body[transformId].transformDeleted.success).to.eql(true); + expect(body[transformId].destIndexDeleted.success).to.eql(true); + expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + await transform.api.waitForTransformNotToExist(transformId); + await transform.api.waitForIndicesNotToExist(destinationIndex); + }); + }); + + describe('with deleteDestIndexPattern setting', function () { + const transformId = 'test3'; + const destinationIndex = generateDestIndex(transformId); + + before(async () => { + await createTransform(transformId, destinationIndex); + await transform.api.createIndices(destinationIndex); + await transform.testResources.createIndexPatternIfNeeded(destinationIndex); + }); + + after(async () => { + await transform.api.deleteIndices(destinationIndex); + await transform.testResources.deleteIndexPattern(destinationIndex); + }); + + it('should delete transform and destination index pattern', async () => { + const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const { body } = await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo, + deleteDestIndex: false, + deleteDestIndexPattern: true, + }) + .expect(200); + + expect(body[transformId].transformDeleted.success).to.eql(true); + expect(body[transformId].destIndexDeleted.success).to.eql(false); + expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + await transform.api.waitForTransformNotToExist(transformId); + await transform.api.waitForIndicesToExist(destinationIndex); + await transform.testResources.assertIndexPatternNotExist(destinationIndex); + }); + }); + + describe('with deleteDestIndex & deleteDestIndexPattern setting', function () { + const transformId = 'test4'; + const destinationIndex = generateDestIndex(transformId); + + before(async () => { + await createTransform(transformId, destinationIndex); + await transform.api.createIndices(destinationIndex); + await transform.testResources.createIndexPatternIfNeeded(destinationIndex); + }); + + after(async () => { + await transform.api.deleteIndices(destinationIndex); + await transform.testResources.deleteIndexPattern(destinationIndex); + }); + + it('should delete transform, destination index, & destination index pattern', async () => { + const transformsInfo: TransformEndpointRequest[] = [{ id: transformId }]; + const { body } = await supertest + .post(`/api/transform/delete_transforms`) + .auth( + USER.TRANSFORM_POWERUSER, + transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER) + ) + .set(COMMON_REQUEST_HEADERS) + .send({ + transformsInfo, + deleteDestIndex: true, + deleteDestIndexPattern: true, + }) + .expect(200); + + expect(body[transformId].transformDeleted.success).to.eql(true); + expect(body[transformId].destIndexDeleted.success).to.eql(true); + expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + await transform.api.waitForTransformNotToExist(transformId); + await transform.api.waitForIndicesNotToExist(destinationIndex); + await transform.testResources.assertIndexPatternNotExist(destinationIndex); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/transform/index.ts b/x-pack/test/api_integration/apis/transform/index.ts new file mode 100644 index 000000000000..93a951a55ece --- /dev/null +++ b/x-pack/test/api_integration/apis/transform/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const transform = getService('transform'); + + describe('Machine Learning', function () { + this.tags(['transform']); + + before(async () => { + await transform.securityCommon.createTransformRoles(); + await transform.securityCommon.createTransformUsers(); + }); + + after(async () => { + await transform.securityCommon.cleanTransformUsers(); + await transform.securityCommon.cleanTransformRoles(); + + await esArchiver.unload('ml/farequote'); + + await transform.testResources.resetKibanaTimeZone(); + }); + + loadTestFile(require.resolve('./delete_transforms')); + }); +} diff --git a/x-pack/test/api_integration/services/index.ts b/x-pack/test/api_integration/services/index.ts index e7e166237c60..2a0327ff5710 100644 --- a/x-pack/test/api_integration/services/index.ts +++ b/x-pack/test/api_integration/services/index.ts @@ -28,6 +28,7 @@ import { InfraLogSourceConfigurationProvider } from './infra_log_source_configur import { MachineLearningProvider } from './ml'; import { IngestManagerProvider } from './ingest_manager'; import { ResolverGeneratorProvider } from './resolver'; +import { TransformProvider } from './transform'; export const services = { ...commonServices, @@ -48,4 +49,5 @@ export const services = { ml: MachineLearningProvider, ingestManager: IngestManagerProvider, resolverGenerator: ResolverGeneratorProvider, + transform: TransformProvider, }; diff --git a/x-pack/test/api_integration/services/transform.ts b/x-pack/test/api_integration/services/transform.ts new file mode 100644 index 000000000000..1403d5d2d67f --- /dev/null +++ b/x-pack/test/api_integration/services/transform.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +import { TransformAPIProvider } from '../../functional/services/transform/api'; +import { TransformSecurityCommonProvider } from '../../functional/services/transform/security_common'; +import { MachineLearningTestResourcesProvider } from '../../functional/services/ml/test_resources'; + +export function TransformProvider(context: FtrProviderContext) { + const api = TransformAPIProvider(context); + const securityCommon = TransformSecurityCommonProvider(context); + const testResources = MachineLearningTestResourcesProvider(context); + + return { + api, + securityCommon, + testResources, + }; +} diff --git a/x-pack/test/functional/services/transform/api.ts b/x-pack/test/functional/services/transform/api.ts index a805f5a3b601..697020fafb19 100644 --- a/x-pack/test/functional/services/transform/api.ts +++ b/x-pack/test/functional/services/transform/api.ts @@ -20,6 +20,21 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { const esSupertest = getService('esSupertest'); return { + async createIndices(indices: string) { + log.debug(`Creating indices: '${indices}'...`); + if ((await es.indices.exists({ index: indices, allowNoIndices: false })) === true) { + log.debug(`Indices '${indices}' already exist. Nothing to create.`); + return; + } + + const createResponse = await es.indices.create({ index: indices }); + expect(createResponse) + .to.have.property('acknowledged') + .eql(true, 'Response for create request indices should be acknowledged.'); + + await this.waitForIndicesToExist(indices, `expected ${indices} to be created`); + }, + async deleteIndices(indices: string) { log.debug(`Deleting indices: '${indices}'...`); if ((await es.indices.exists({ index: indices, allowNoIndices: false })) === false) { @@ -34,11 +49,25 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { .to.have.property('acknowledged') .eql(true, 'Response for delete request should be acknowledged'); - await retry.waitForWithTimeout(`'${indices}' indices to be deleted`, 30 * 1000, async () => { + await this.waitForIndicesNotToExist(indices, `expected indices '${indices}' to be deleted`); + }, + + async waitForIndicesToExist(indices: string, errorMsg?: string) { + await retry.tryForTime(30 * 1000, async () => { + if ((await es.indices.exists({ index: indices, allowNoIndices: false })) === true) { + return true; + } else { + throw new Error(errorMsg || `indices '${indices}' should exist`); + } + }); + }, + + async waitForIndicesNotToExist(indices: string, errorMsg?: string) { + await retry.tryForTime(30 * 1000, async () => { if ((await es.indices.exists({ index: indices, allowNoIndices: false })) === false) { return true; } else { - throw new Error(`expected indices '${indices}' to be deleted`); + throw new Error(errorMsg || `indices '${indices}' should not exist`); } }); }, @@ -63,9 +92,7 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { async getTransformState(transformId: string): Promise { const stats = await this.getTransformStats(transformId); - const state: TRANSFORM_STATE = stats.state; - - return state; + return stats.state; }, async waitForTransformState(transformId: string, expectedState: TRANSFORM_STATE) { @@ -96,8 +123,8 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { }); }, - async getTransform(transformId: string) { - return await esSupertest.get(`/_transform/${transformId}`).expect(200); + async getTransform(transformId: string, expectedCode = 200) { + return await esSupertest.get(`/_transform/${transformId}`).expect(expectedCode); }, async createTransform(transformConfig: TransformPivotConfig) { @@ -105,11 +132,27 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) { log.debug(`Creating transform with id '${transformId}'...`); await esSupertest.put(`/_transform/${transformId}`).send(transformConfig).expect(200); - await retry.waitForWithTimeout(`'${transformId}' to be created`, 5 * 1000, async () => { - if (await this.getTransform(transformId)) { + await this.waitForTransformToExist( + transformId, + `expected transform '${transformId}' to be created` + ); + }, + + async waitForTransformToExist(transformId: string, errorMsg?: string) { + await retry.waitForWithTimeout(`'${transformId}' to exist`, 5 * 1000, async () => { + if (await this.getTransform(transformId, 200)) { return true; } else { - throw new Error(`expected transform '${transformId}' to be created`); + throw new Error(errorMsg || `expected transform '${transformId}' to exist`); + } + }); + }, + async waitForTransformNotToExist(transformId: string, errorMsg?: string) { + await retry.waitForWithTimeout(`'${transformId}' to exist`, 5 * 1000, async () => { + if (await this.getTransform(transformId, 404)) { + return true; + } else { + throw new Error(errorMsg || `expected transform '${transformId}' to not exist`); } }); },