[ML] Add ability to delete target index & index pattern when deleting DFA job (#66934)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Quynh Nguyen 2020-06-01 10:53:33 -05:00 committed by GitHub
parent cf2aebf67a
commit f31330a01b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 798 additions and 43 deletions

View file

@ -0,0 +1,11 @@
/*
* 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 interface DeleteDataFrameAnalyticsWithIndexStatus {
success: boolean;
error?: CustomHttpResponseOptions<ResponseError>;
}

View file

@ -5,20 +5,41 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { fireEvent, render } from '@testing-library/react';
import * as CheckPrivilige from '../../../../../capabilities/check_capabilities';
import { DeleteAction } from './action_delete';
import mockAnalyticsListItem from './__mocks__/analytics_list_item.json';
import { DeleteAction } from './action_delete';
import { I18nProvider } from '@kbn/i18n/react';
import {
coreMock as mockCoreServices,
i18nServiceMock,
} from '../../../../../../../../../../src/core/public/mocks';
jest.mock('../../../../../capabilities/check_capabilities', () => ({
checkPermission: jest.fn(() => false),
createPermissionFailureMessage: jest.fn(),
}));
jest.mock('../../../../../../application/util/dependency_cache', () => ({
getToastNotifications: () => ({ addSuccess: jest.fn(), addDanger: jest.fn() }),
}));
jest.mock('../../../../../contexts/kibana', () => ({
useMlKibana: () => ({
services: mockCoreServices.createStart(),
}),
}));
export const MockI18nService = i18nServiceMock.create();
export const I18nServiceConstructor = jest.fn().mockImplementation(() => MockI18nService);
jest.doMock('@kbn/i18n', () => ({
I18nService: I18nServiceConstructor,
}));
describe('DeleteAction', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('When canDeleteDataFrameAnalytics permission is false, button should be disabled.', () => {
const { getByTestId } = render(<DeleteAction item={mockAnalyticsListItem} />);
expect(getByTestId('mlAnalyticsJobDeleteButton')).toHaveAttribute('disabled');
@ -46,4 +67,24 @@ describe('DeleteAction', () => {
expect(getByTestId('mlAnalyticsJobDeleteButton')).toHaveAttribute('disabled');
});
describe('When delete model is open', () => {
test('should allow to delete target index by default.', () => {
const mock = jest.spyOn(CheckPrivilige, 'checkPermission');
mock.mockImplementation((p) => p === 'canDeleteDataFrameAnalytics');
const { getByTestId, queryByTestId } = render(
<I18nProvider>
<DeleteAction item={mockAnalyticsListItem} />
</I18nProvider>
);
const deleteButton = getByTestId('mlAnalyticsJobDeleteButton');
fireEvent.click(deleteButton);
expect(getByTestId('mlAnalyticsJobDeleteModal')).toBeInTheDocument();
expect(getByTestId('mlAnalyticsJobDeleteIndexSwitch')).toBeInTheDocument();
const mlAnalyticsJobDeleteIndexSwitch = getByTestId('mlAnalyticsJobDeleteIndexSwitch');
expect(mlAnalyticsJobDeleteIndexSwitch).toHaveAttribute('aria-checked', 'true');
expect(queryByTestId('mlAnalyticsJobDeleteIndexPatternSwitch')).toBeNull();
mock.mockRestore();
});
});
});

View file

@ -4,24 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useState } from 'react';
import React, { Fragment, FC, useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiOverlayMask,
EuiToolTip,
EuiSwitch,
EuiFlexGroup,
EuiFlexItem,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import { deleteAnalytics } from '../../services/analytics_service';
import { IIndexPattern } from 'src/plugins/data/common';
import { FormattedMessage } from '@kbn/i18n/react';
import {
deleteAnalytics,
deleteAnalyticsAndDestIndex,
canDeleteIndex,
} from '../../services/analytics_service';
import {
checkPermission,
createPermissionFailureMessage,
} from '../../../../../capabilities/check_capabilities';
import { useMlKibana } from '../../../../../contexts/kibana';
import { isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common';
import { extractErrorMessage } from '../../../../../util/error_utils';
interface DeleteActionProps {
item: DataFrameAnalyticsListRow;
@ -29,17 +37,99 @@ interface DeleteActionProps {
export const DeleteAction: FC<DeleteActionProps> = ({ item }) => {
const disabled = isDataFrameAnalyticsRunning(item.stats.state);
const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics');
const [isModalVisible, setModalVisible] = useState(false);
const [deleteTargetIndex, setDeleteTargetIndex] = useState<boolean>(true);
const [deleteIndexPattern, setDeleteIndexPattern] = useState<boolean>(true);
const [userCanDeleteIndex, setUserCanDeleteIndex] = useState<boolean>(false);
const [indexPatternExists, setIndexPatternExists] = useState<boolean>(false);
const { savedObjects, notifications } = useMlKibana().services;
const savedObjectsClient = savedObjects.client;
const indexName = item.config.dest.index;
const checkIndexPatternExists = async () => {
try {
const response = await savedObjectsClient.find<IIndexPattern>({
type: 'index-pattern',
perPage: 10,
search: `"${indexName}"`,
searchFields: ['title'],
fields: ['title'],
});
const ip = response.savedObjects.find(
(obj) => obj.attributes.title.toLowerCase() === indexName.toLowerCase()
);
if (ip !== undefined) {
setIndexPatternExists(true);
}
} catch (e) {
const { toasts } = notifications;
const error = extractErrorMessage(e);
toasts.addDanger(
i18n.translate(
'xpack.ml.dataframe.analyticsList.errorWithCheckingIfIndexPatternExistsNotificationErrorMessage',
{
defaultMessage:
'An error occurred checking if index pattern {indexPattern} exists: {error}',
values: { indexPattern: indexName, error },
}
)
);
}
};
const checkUserIndexPermission = () => {
try {
const userCanDelete = canDeleteIndex(indexName);
if (userCanDelete) {
setUserCanDeleteIndex(true);
}
} catch (e) {
const { toasts } = notifications;
const error = extractErrorMessage(e);
toasts.addDanger(
i18n.translate(
'xpack.ml.dataframe.analyticsList.errorWithCheckingIfUserCanDeleteIndexNotificationErrorMessage',
{
defaultMessage:
'An error occurred checking if user can delete {destinationIndex}: {error}',
values: { destinationIndex: indexName, error },
}
)
);
}
};
useEffect(() => {
// Check if an index pattern exists corresponding to current DFA job
// if pattern does exist, show it to user
checkIndexPatternExists();
// Check if an user has permission to delete the index & index pattern
checkUserIndexPermission();
}, []);
const closeModal = () => setModalVisible(false);
const deleteAndCloseModal = () => {
setModalVisible(false);
deleteAnalytics(item);
if ((userCanDeleteIndex && deleteTargetIndex) || (userCanDeleteIndex && deleteIndexPattern)) {
deleteAnalyticsAndDestIndex(
item,
deleteTargetIndex,
indexPatternExists && deleteIndexPattern
);
} else {
deleteAnalytics(item);
}
};
const openModal = () => setModalVisible(true);
const toggleDeleteIndex = () => setDeleteTargetIndex(!deleteTargetIndex);
const toggleDeleteIndexPattern = () => setDeleteIndexPattern(!deleteIndexPattern);
const buttonDeleteText = i18n.translate('xpack.ml.dataframe.analyticsList.deleteActionName', {
defaultMessage: 'Delete',
@ -84,8 +174,9 @@ export const DeleteAction: FC<DeleteActionProps> = ({ item }) => {
<Fragment>
{deleteButton}
{isModalVisible && (
<EuiOverlayMask>
<EuiOverlayMask data-test-subj="mlAnalyticsJobDeleteOverlay">
<EuiConfirmModal
data-test-subj="mlAnalyticsJobDeleteModal"
title={i18n.translate('xpack.ml.dataframe.analyticsList.deleteModalTitle', {
defaultMessage: 'Delete {analyticsId}',
values: { analyticsId: item.config.id },
@ -108,10 +199,47 @@ export const DeleteAction: FC<DeleteActionProps> = ({ item }) => {
buttonColor="danger"
>
<p>
{i18n.translate('xpack.ml.dataframe.analyticsList.deleteModalBody', {
defaultMessage: `Are you sure you want to delete this analytics job? The analytics job's destination index and optional Kibana index pattern will not be deleted.`,
})}
<FormattedMessage
id="xpack.ml.dataframe.analyticsList.deleteModalBody"
defaultMessage="Are you sure you want to delete this analytics job?"
/>
</p>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
{userCanDeleteIndex && (
<EuiSwitch
data-test-subj="mlAnalyticsJobDeleteIndexSwitch"
style={{ paddingBottom: 10 }}
label={i18n.translate(
'xpack.ml.dataframe.analyticsList.deleteDestinationIndexTitle',
{
defaultMessage: 'Delete destination index {indexName}',
values: { indexName },
}
)}
checked={deleteTargetIndex}
onChange={toggleDeleteIndex}
/>
)}
</EuiFlexItem>
<EuiFlexItem>
{userCanDeleteIndex && indexPatternExists && (
<EuiSwitch
data-test-subj="mlAnalyticsJobDeleteIndexPatternSwitch"
label={i18n.translate(
'xpack.ml.dataframe.analyticsList.deleteTargetIndexPatternTitle',
{
defaultMessage: 'Delete index pattern {indexPattern}',
values: { indexPattern: indexName },
}
)}
checked={deleteIndexPattern}
onChange={toggleDeleteIndexPattern}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiConfirmModal>
</EuiOverlayMask>
)}

View file

@ -3,17 +3,15 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { getToastNotifications } from '../../../../../util/dependency_cache';
import { ml } from '../../../../../services/ml_api_service';
import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common';
import {
isDataFrameAnalyticsFailed,
DataFrameAnalyticsListRow,
} from '../../components/analytics_list/common';
import { extractErrorMessage } from '../../../../../util/error_utils';
export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => {
const toastNotifications = getToastNotifications();
@ -24,18 +22,139 @@ export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => {
await ml.dataFrameAnalytics.deleteDataFrameAnalytics(d.config.id);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage', {
defaultMessage: 'Request to delete data frame analytics {analyticsId} acknowledged.',
defaultMessage: 'Request to delete data frame analytics job {analyticsId} acknowledged.',
values: { analyticsId: d.config.id },
})
);
} catch (e) {
const error = extractErrorMessage(e);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', {
defaultMessage:
'An error occurred deleting the data frame analytics {analyticsId}: {error}',
values: { analyticsId: d.config.id, error: JSON.stringify(e) },
'An error occurred deleting the data frame analytics job {analyticsId}: {error}',
values: { analyticsId: d.config.id, error },
})
);
}
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.REFRESH);
};
export const deleteAnalyticsAndDestIndex = async (
d: DataFrameAnalyticsListRow,
deleteDestIndex: boolean,
deleteDestIndexPattern: boolean
) => {
const toastNotifications = getToastNotifications();
const destinationIndex = Array.isArray(d.config.dest.index)
? d.config.dest.index[0]
: d.config.dest.index;
try {
if (isDataFrameAnalyticsFailed(d.stats.state)) {
await ml.dataFrameAnalytics.stopDataFrameAnalytics(d.config.id, true);
}
const status = await ml.dataFrameAnalytics.deleteDataFrameAnalyticsAndDestIndex(
d.config.id,
deleteDestIndex,
deleteDestIndexPattern
);
if (status.analyticsJobDeleted?.success) {
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage', {
defaultMessage: 'Request to delete data frame analytics job {analyticsId} acknowledged.',
values: { analyticsId: d.config.id },
})
);
}
if (status.analyticsJobDeleted?.error) {
const error = extractErrorMessage(status.analyticsJobDeleted.error);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', {
defaultMessage:
'An error occurred deleting the data frame analytics job {analyticsId}: {error}',
values: { analyticsId: d.config.id, error },
})
);
}
if (status.destIndexDeleted?.success) {
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexSuccessMessage', {
defaultMessage: 'Request to delete destination index {destinationIndex} acknowledged.',
values: { destinationIndex },
})
);
}
if (status.destIndexDeleted?.error) {
const error = extractErrorMessage(status.destIndexDeleted.error);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexErrorMessage', {
defaultMessage:
'An error occurred deleting destination index {destinationIndex}: {error}',
values: { destinationIndex, error },
})
);
}
if (status.destIndexPatternDeleted?.success) {
toastNotifications.addSuccess(
i18n.translate(
'xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexPatternSuccessMessage',
{
defaultMessage: 'Request to delete index pattern {destinationIndex} acknowledged.',
values: { destinationIndex },
}
)
);
}
if (status.destIndexPatternDeleted?.error) {
const error = extractErrorMessage(status.destIndexPatternDeleted.error);
toastNotifications.addDanger(
i18n.translate(
'xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexPatternErrorMessage',
{
defaultMessage: 'An error occurred deleting index pattern {destinationIndex}: {error}',
values: { destinationIndex, error },
}
)
);
}
} catch (e) {
const error = extractErrorMessage(e);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', {
defaultMessage:
'An error occurred deleting the data frame analytics job {analyticsId}: {error}',
values: { analyticsId: d.config.id, error },
})
);
}
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.REFRESH);
};
export const canDeleteIndex = async (indexName: string) => {
const toastNotifications = getToastNotifications();
try {
const privilege = await ml.hasPrivileges({
index: [
{
names: [indexName], // uses wildcard
privileges: ['delete_index'],
},
],
});
if (!privilege) {
return false;
}
return privilege.securityDisabled === true || privilege.has_all_requested === true;
} catch (e) {
const error = extractErrorMessage(e);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsPrivilegeErrorMessage', {
defaultMessage: 'User does not have permission to delete index {indexName}: {error}',
values: { indexName, error },
})
);
}
};

View file

@ -3,8 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { getAnalyticsFactory } from './get_analytics';
export { deleteAnalytics } from './delete_analytics';
export { deleteAnalytics, deleteAnalyticsAndDestIndex, canDeleteIndex } from './delete_analytics';
export { startAnalytics } from './start_analytics';
export { stopAnalytics } from './stop_analytics';

View file

@ -10,6 +10,7 @@ import { basePath } from './index';
import { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common';
import { DataFrameAnalyticsConfig } from '../../data_frame_analytics/common';
import { DeepPartial } from '../../../../common/types/common';
import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../../../common/types/data_frame_analytics';
export interface GetDataFrameAnalyticsStatsResponseOk {
node_failures?: object;
@ -32,6 +33,13 @@ interface GetDataFrameAnalyticsResponse {
data_frame_analytics: DataFrameAnalyticsConfig[];
}
interface DeleteDataFrameAnalyticsWithIndexResponse {
acknowledged: boolean;
analyticsJobDeleted: DeleteDataFrameAnalyticsWithIndexStatus;
destIndexDeleted: DeleteDataFrameAnalyticsWithIndexStatus;
destIndexPatternDeleted: DeleteDataFrameAnalyticsWithIndexStatus;
}
export const dataFrameAnalytics = {
getDataFrameAnalytics(analyticsId?: string) {
const analyticsIdString = analyticsId !== undefined ? `/${analyticsId}` : '';
@ -86,6 +94,17 @@ export const dataFrameAnalytics = {
method: 'DELETE',
});
},
deleteDataFrameAnalyticsAndDestIndex(
analyticsId: string,
deleteDestIndex: boolean,
deleteDestIndexPattern: boolean
) {
return http<DeleteDataFrameAnalyticsWithIndexResponse>({
path: `${basePath()}/data_frame/analytics/${analyticsId}`,
query: { deleteDestIndex, deleteDestIndexPattern },
method: 'DELETE',
});
},
startDataFrameAnalytics(analyticsId: string) {
return http<any>({
path: `${basePath()}/data_frame/analytics/${analyticsId}/_start`,

View file

@ -95,7 +95,6 @@ export const jobs = {
body,
});
},
closeJobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<any>({

View file

@ -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 { CustomHttpResponseOptions, ResponseError } from 'kibana/server';
export const extractErrorMessage = (
error: CustomHttpResponseOptions<ResponseError> | 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;
};

View file

@ -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 { SavedObjectsClientContract } from 'kibana/server';
import { IIndexPattern } from 'src/plugins/data/server';
export class IndexPatternHandler {
constructor(private savedObjectsClient: SavedObjectsClientContract) {}
// returns a id based on an index pattern name
async getIndexPatternId(indexName: string) {
const response = await this.savedObjectsClient.find<IIndexPattern>({
type: 'index-pattern',
perPage: 10,
search: `"${indexName}"`,
searchFields: ['title'],
fields: ['title'],
});
const ip = response.saved_objects.find(
(obj) => obj.attributes.title.toLowerCase() === indexName.toLowerCase()
);
return ip?.id;
}
async deleteIndexPatternById(indexId: string) {
return await this.savedObjectsClient.delete('index-pattern', indexId);
}
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { RequestHandlerContext } from 'kibana/server';
import { wrapError } from '../client/error_wrapper';
import { analyticsAuditMessagesProvider } from '../models/data_frame_analytics/analytics_audit_messages';
import { RouteInitialization } from '../types';
@ -13,12 +14,48 @@ import {
dataAnalyticsExplainSchema,
analyticsIdSchema,
stopsDataFrameAnalyticsJobQuerySchema,
deleteDataFrameAnalyticsJobSchema,
} from './schemas/data_analytics_schema';
import { IndexPatternHandler } from '../models/data_frame_analytics/index_patterns';
import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../common/types/data_frame_analytics';
function getIndexPatternId(context: RequestHandlerContext, patternName: string) {
const iph = new IndexPatternHandler(context.core.savedObjects.client);
return iph.getIndexPatternId(patternName);
}
function deleteDestIndexPatternById(context: RequestHandlerContext, indexPatternId: string) {
const iph = new IndexPatternHandler(context.core.savedObjects.client);
return iph.deleteIndexPatternById(indexPatternId);
}
/**
* Routes for the data frame analytics
*/
export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitialization) {
async function userCanDeleteIndex(
context: RequestHandlerContext,
destinationIndex: string
): Promise<boolean> {
if (!mlLicense.isSecurityEnabled()) {
return true;
}
const privilege = await context.ml!.mlClient.callAsCurrentUser('ml.privilegeCheck', {
body: {
index: [
{
names: [destinationIndex], // uses wildcard
privileges: ['delete_index'],
},
],
},
});
if (!privilege) {
return false;
}
return privilege.has_all_requested === true;
}
/**
* @apiGroup DataFrameAnalytics
*
@ -277,6 +314,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat
path: '/api/ml/data_frame/analytics/{analyticsId}',
validate: {
params: analyticsIdSchema,
query: deleteDataFrameAnalyticsJobSchema,
},
options: {
tags: ['access:ml:canDeleteDataFrameAnalytics'],
@ -285,12 +323,78 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const { analyticsId } = request.params;
const results = await context.ml!.mlClient.callAsCurrentUser(
'ml.deleteDataFrameAnalytics',
{
analyticsId,
const { deleteDestIndex, deleteDestIndexPattern } = request.query;
let destinationIndex: string | undefined;
const analyticsJobDeleted: DeleteDataFrameAnalyticsWithIndexStatus = { success: false };
const destIndexDeleted: DeleteDataFrameAnalyticsWithIndexStatus = { success: false };
const destIndexPatternDeleted: DeleteDataFrameAnalyticsWithIndexStatus = {
success: false,
};
// Check if analyticsId is valid and get destination index
if (deleteDestIndex || deleteDestIndexPattern) {
try {
const dfa = await context.ml!.mlClient.callAsCurrentUser('ml.getDataFrameAnalytics', {
analyticsId,
});
if (Array.isArray(dfa.data_frame_analytics) && dfa.data_frame_analytics.length > 0) {
destinationIndex = dfa.data_frame_analytics[0].dest.index;
}
} catch (e) {
return response.customError(wrapError(e));
}
);
// If user checks box to delete the destinationIndex associated with the job
if (destinationIndex && deleteDestIndex) {
// Verify if user has privilege to delete the destination index
const userCanDeleteDestIndex = await userCanDeleteIndex(context, destinationIndex);
// If user does have privilege to delete the index, then delete the index
if (userCanDeleteDestIndex) {
try {
await context.ml!.mlClient.callAsCurrentUser('indices.delete', {
index: destinationIndex,
});
destIndexDeleted.success = true;
} catch (deleteIndexError) {
destIndexDeleted.error = wrapError(deleteIndexError);
}
} else {
return response.forbidden();
}
}
// 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(context, destinationIndex);
if (indexPatternId) {
await deleteDestIndexPatternById(context, indexPatternId);
}
destIndexPatternDeleted.success = true;
} catch (deleteDestIndexPatternError) {
destIndexPatternDeleted.error = wrapError(deleteDestIndexPatternError);
}
}
}
// Grab the target index from the data frame analytics job id
// Delete the data frame analytics
try {
await context.ml!.mlClient.callAsCurrentUser('ml.deleteDataFrameAnalytics', {
analyticsId,
});
analyticsJobDeleted.success = true;
} catch (deleteDFAError) {
analyticsJobDeleted.error = wrapError(deleteDFAError);
if (analyticsJobDeleted.error.statusCode === 404) {
return response.notFound();
}
}
const results = {
analyticsJobDeleted,
destIndexDeleted,
destIndexPatternDeleted,
};
return response.ok({
body: results,
});

View file

@ -60,6 +60,14 @@ export const analyticsIdSchema = schema.object({
analyticsId: schema.string(),
});
export const deleteDataFrameAnalyticsJobSchema = schema.object({
/**
* Analytics Destination Index
*/
deleteDestIndex: schema.maybe(schema.boolean()),
deleteDestIndexPattern: schema.maybe(schema.boolean()),
});
export const stopsDataFrameAnalyticsJobQuerySchema = schema.object({
force: schema.maybe(schema.boolean()),
});

View file

@ -0,0 +1,218 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
import { USER } from '../../../../functional/services/ml/security_common';
import { DataFrameAnalyticsConfig } from '../../../../../plugins/ml/public/application/data_frame_analytics/common';
import { DeepPartial } from '../../../../../plugins/ml/common/types/common';
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common';
export default ({ getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const supertest = getService('supertestWithoutAuth');
const ml = getService('ml');
const jobId = `bm_${Date.now()}`;
const generateDestinationIndex = (analyticsId: string) => `user-${analyticsId}`;
const commonJobConfig = {
source: {
index: ['ft_bank_marketing'],
query: {
match_all: {},
},
},
analysis: {
classification: {
dependent_variable: 'y',
training_percent: 20,
},
},
analyzed_fields: {
includes: [],
excludes: [],
},
model_memory_limit: '350mb',
};
const testJobConfigs: Array<DeepPartial<DataFrameAnalyticsConfig>> = [
'Test delete job only',
'Test delete job and target index',
'Test delete job and index pattern',
'Test delete job, target index, and index pattern',
].map((description, idx) => {
const analyticsId = `${jobId}_${idx + 1}`;
return {
id: analyticsId,
description,
dest: {
index: generateDestinationIndex(analyticsId),
results_field: 'ml',
},
...commonJobConfig,
};
});
async function createJobs(mockJobConfigs: Array<DeepPartial<DataFrameAnalyticsConfig>>) {
for (const jobConfig of mockJobConfigs) {
await ml.api.createDataFrameAnalyticsJob(jobConfig as DataFrameAnalyticsConfig);
}
}
describe('DELETE data_frame/analytics', () => {
before(async () => {
await esArchiver.loadIfNeeded('ml/bm_classification');
await ml.testResources.setKibanaTimeZoneToUTC();
await createJobs(testJobConfigs);
});
after(async () => {
await ml.api.cleanMlIndices();
});
describe('DeleteDataFrameAnalytics', () => {
it('should delete analytics jobs by id', async () => {
const analyticsId = `${jobId}_1`;
const { body } = await supertest
.delete(`/api/ml/data_frame/analytics/${analyticsId}`)
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_REQUEST_HEADERS)
.expect(200);
expect(body.analyticsJobDeleted.success).to.eql(true);
await ml.api.waitForDataFrameAnalyticsJobNotToExist(analyticsId);
});
it('should not allow to retrieve analytics jobs for unauthorized user', async () => {
const analyticsId = `${jobId}_2`;
const { body } = await supertest
.delete(`/api/ml/data_frame/analytics/${analyticsId}`)
.auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
.set(COMMON_REQUEST_HEADERS)
.expect(404);
expect(body.error).to.eql('Not Found');
expect(body.message).to.eql('Not Found');
await ml.api.waitForDataFrameAnalyticsJobToExist(analyticsId);
});
it('should not allow to retrieve analytics jobs for the user with only view permission', async () => {
const analyticsId = `${jobId}_2`;
const { body } = await supertest
.delete(`/api/ml/data_frame/analytics/${analyticsId}`)
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
.set(COMMON_REQUEST_HEADERS)
.expect(404);
expect(body.error).to.eql('Not Found');
expect(body.message).to.eql('Not Found');
await ml.api.waitForDataFrameAnalyticsJobToExist(analyticsId);
});
it('should show 404 error if job does not exist or has already been deleted', async () => {
const { body } = await supertest
.delete(`/api/ml/data_frame/analytics/${jobId}_invalid`)
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_REQUEST_HEADERS)
.expect(404);
expect(body.error).to.eql('Not Found');
expect(body.message).to.eql('Not Found');
});
describe('with deleteDestIndex setting', function () {
const analyticsId = `${jobId}_2`;
const destinationIndex = generateDestinationIndex(analyticsId);
before(async () => {
await ml.api.createIndices(destinationIndex);
await ml.api.assertIndicesExist(destinationIndex);
});
after(async () => {
await ml.api.deleteIndices(destinationIndex);
});
it('should delete job and destination index by id', async () => {
const { body } = await supertest
.delete(`/api/ml/data_frame/analytics/${analyticsId}`)
.query({ deleteDestIndex: true })
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_REQUEST_HEADERS)
.expect(200);
expect(body.analyticsJobDeleted.success).to.eql(true);
expect(body.destIndexDeleted.success).to.eql(true);
expect(body.destIndexPatternDeleted.success).to.eql(false);
await ml.api.waitForDataFrameAnalyticsJobNotToExist(analyticsId);
await ml.api.assertIndicesNotToExist(destinationIndex);
});
});
describe('with deleteDestIndexPattern setting', function () {
const analyticsId = `${jobId}_3`;
const destinationIndex = generateDestinationIndex(analyticsId);
before(async () => {
// Mimic real job by creating index pattern after job is created
await ml.testResources.createIndexPatternIfNeeded(destinationIndex);
});
after(async () => {
await ml.testResources.deleteIndexPattern(destinationIndex);
});
it('should delete job and index pattern by id', async () => {
const { body } = await supertest
.delete(`/api/ml/data_frame/analytics/${analyticsId}`)
.query({ deleteDestIndexPattern: true })
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_REQUEST_HEADERS)
.expect(200);
expect(body.analyticsJobDeleted.success).to.eql(true);
expect(body.destIndexDeleted.success).to.eql(false);
expect(body.destIndexPatternDeleted.success).to.eql(true);
await ml.api.waitForDataFrameAnalyticsJobNotToExist(analyticsId);
await ml.testResources.assertIndexPatternNotExist(destinationIndex);
});
});
describe('with deleteDestIndex & deleteDestIndexPattern setting', function () {
const analyticsId = `${jobId}_4`;
const destinationIndex = generateDestinationIndex(analyticsId);
before(async () => {
// Mimic real job by creating target index & index pattern after DFA job is created
await ml.api.createIndices(destinationIndex);
await ml.api.assertIndicesExist(destinationIndex);
await ml.testResources.createIndexPatternIfNeeded(destinationIndex);
});
after(async () => {
await ml.api.deleteIndices(destinationIndex);
await ml.testResources.deleteIndexPattern(destinationIndex);
});
it('deletes 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 })
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_REQUEST_HEADERS)
.expect(200);
expect(body.analyticsJobDeleted.success).to.eql(true);
expect(body.destIndexDeleted.success).to.eql(true);
expect(body.destIndexPatternDeleted.success).to.eql(true);
await ml.api.waitForDataFrameAnalyticsJobNotToExist(analyticsId);
await ml.api.assertIndicesNotToExist(destinationIndex);
await ml.testResources.assertIndexPatternNotExist(destinationIndex);
});
});
});
});
};

View file

@ -9,5 +9,6 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('data frame analytics', function () {
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./delete'));
});
}

View file

@ -9,9 +9,9 @@ import { DataFrameAnalyticsConfig } from '../../../../plugins/ml/public/applicat
import { FtrProviderContext } from '../../ftr_provider_context';
import { JOB_STATE, DATAFEED_STATE } from '../../../../plugins/ml/common/constants/states';
import { DATAFEED_STATE, JOB_STATE } from '../../../../plugins/ml/common/constants/states';
import { DATA_FRAME_TASK_STATE } from '../../../../plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common';
import { Job, Datafeed } from '../../../../plugins/ml/common/types/anomaly_detection_jobs';
import { Datafeed, Job } from '../../../../plugins/ml/common/types/anomaly_detection_jobs';
export type MlApi = ProvidedType<typeof MachineLearningAPIProvider>;
@ -110,6 +110,21 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
);
},
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.assertIndicesExist(indices);
},
async deleteIndices(indices: string) {
log.debug(`Deleting indices: '${indices}'...`);
if ((await es.indices.exists({ index: indices, allowNoIndices: false })) === false) {
@ -122,15 +137,9 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
});
expect(deleteResponse)
.to.have.property('acknowledged')
.eql(true, 'Response for delete request should be acknowledged');
.eql(true, 'Response for delete request should be acknowledged.');
await retry.waitForWithTimeout(`'${indices}' indices to be deleted`, 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`);
}
});
await this.assertIndicesNotToExist(indices);
},
async cleanMlIndices() {
@ -251,6 +260,16 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
});
},
async assertIndicesNotToExist(indices: string) {
await retry.tryForTime(30 * 1000, async () => {
if ((await es.indices.exists({ index: indices, allowNoIndices: false })) === false) {
return true;
} else {
throw new Error(`indices '${indices}' should not exist`);
}
});
},
async assertIndicesNotEmpty(indices: string) {
await retry.tryForTime(30 * 1000, async () => {
const response = await es.search({
@ -394,9 +413,9 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
await this.waitForJobState(jobConfig.job_id, JOB_STATE.CLOSED);
},
async getDataFrameAnalyticsJob(analyticsId: string) {
async getDataFrameAnalyticsJob(analyticsId: string, statusCode = 200) {
log.debug(`Fetching data frame analytics job '${analyticsId}'...`);
return await esSupertest.get(`/_ml/data_frame/analytics/${analyticsId}`).expect(200);
return await esSupertest.get(`/_ml/data_frame/analytics/${analyticsId}`).expect(statusCode);
},
async waitForDataFrameAnalyticsJobToExist(analyticsId: string) {
@ -409,6 +428,16 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
});
},
async waitForDataFrameAnalyticsJobNotToExist(analyticsId: string) {
await retry.waitForWithTimeout(`'${analyticsId}' not to exist`, 5 * 1000, async () => {
if (await this.getDataFrameAnalyticsJob(analyticsId, 404)) {
return true;
} else {
throw new Error(`expected data frame analytics job '${analyticsId}' not to exist`);
}
});
},
async createDataFrameAnalyticsJob(jobConfig: DataFrameAnalyticsConfig) {
const { id: analyticsId, ...analyticsConfig } = jobConfig;
log.debug(`Creating data frame analytic job with id '${analyticsId}'...`);

View file

@ -5,7 +5,6 @@
*/
import { ProvidedType } from '@kbn/test/types/ftr';
import { savedSearches } from './test_resources_data';
import { COMMON_REQUEST_HEADERS } from './common';
import { FtrProviderContext } from '../../ftr_provider_context';
@ -24,6 +23,7 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
const kibanaServer = getService('kibanaServer');
const log = getService('log');
const supertest = getService('supertest');
const retry = getService('retry');
return {
async setKibanaTimeZoneToUTC() {
@ -98,6 +98,21 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
}
},
async assertIndexPatternNotExist(title: string) {
await retry.waitForWithTimeout(
`index pattern '${title}' to not exist`,
5 * 1000,
async () => {
const indexPatternId = await this.getIndexPatternId(title);
if (!indexPatternId) {
return true;
} else {
throw new Error(`Index pattern '${title}' should not exist.`);
}
}
);
},
async createSavedSearch(title: string, body: object): Promise<string> {
log.debug(`Creating saved search with title '${title}'`);