[Upgrade Assistant] Auto upgrade ML job model snapshots (#100066) (#104091)

# Conflicts:
#	x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/cell.tsx
#	x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.tsx
This commit is contained in:
Alison Goryachev 2021-07-01 10:47:59 -04:00 committed by GitHub
parent c78eb8a6cc
commit b5f7aeb7fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2078 additions and 215 deletions

View file

@ -0,0 +1,360 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act } from 'react-dom/test-utils';
import { MlAction, UpgradeAssistantStatus } from '../../common/types';
import { ClusterTestBed, setupClusterPage, setupEnvironment } from './helpers';
describe('Cluster tab', () => {
let testBed: ClusterTestBed;
const { server, httpRequestsMockHelpers } = setupEnvironment();
afterAll(() => {
server.restore();
});
describe('with deprecations', () => {
const snapshotId = '1';
const jobId = 'deprecation_check_job';
const upgradeStatusMockResponse: UpgradeAssistantStatus = {
readyForUpgrade: false,
cluster: [
{
level: 'critical',
message:
'model snapshot [1] for job [deprecation_check_job] needs to be deleted or upgraded',
details:
'model snapshot [%s] for job [%s] supports minimum version [%s] and needs to be at least [%s]',
url: 'doc_url',
correctiveAction: {
type: 'mlSnapshot',
snapshotId,
jobId,
},
},
],
indices: [],
};
beforeEach(async () => {
httpRequestsMockHelpers.setLoadEsDeprecationsResponse(upgradeStatusMockResponse);
httpRequestsMockHelpers.setLoadDeprecationLoggingResponse({ isEnabled: true });
await act(async () => {
testBed = await setupClusterPage({ isReadOnlyMode: false });
});
const { actions, component } = testBed;
component.update();
// Navigate to the cluster tab
await act(async () => {
actions.clickTab('cluster');
});
component.update();
});
test('renders deprecations', () => {
const { exists } = testBed;
expect(exists('clusterTabContent')).toBe(true);
expect(exists('deprecationsContainer')).toBe(true);
});
describe('fix ml snapshots button', () => {
let flyout: Element | null;
beforeEach(async () => {
const { component, actions, exists, find } = testBed;
expect(exists('deprecationsContainer')).toBe(true);
// Open all deprecations
actions.clickExpandAll();
// The data-test-subj is derived from the deprecation message
const accordionTestSubj = `depgroup_${upgradeStatusMockResponse.cluster[0].message
.split(' ')
.join('_')}`;
await act(async () => {
find(`${accordionTestSubj}.fixMlSnapshotsButton`).simulate('click');
});
component.update();
// We need to read the document "body" as the flyout is added there and not inside
// the component DOM tree.
flyout = document.body.querySelector('[data-test-subj="fixSnapshotsFlyout"]');
expect(flyout).not.toBe(null);
expect(flyout!.textContent).toContain('Upgrade or delete model snapshot');
});
test('upgrades snapshots', async () => {
const { component } = testBed;
const upgradeButton: HTMLButtonElement | null = flyout!.querySelector(
'[data-test-subj="upgradeSnapshotButton"]'
);
httpRequestsMockHelpers.setUpgradeMlSnapshotResponse({
nodeId: 'my_node',
snapshotId,
jobId,
status: 'in_progress',
});
await act(async () => {
upgradeButton!.click();
});
component.update();
// First, we expect a POST request to upgrade the snapshot
const upgradeRequest = server.requests[server.requests.length - 2];
expect(upgradeRequest.method).toBe('POST');
expect(upgradeRequest.url).toBe('/api/upgrade_assistant/ml_snapshots');
// Next, we expect a GET request to check the status of the upgrade
const statusRequest = server.requests[server.requests.length - 1];
expect(statusRequest.method).toBe('GET');
expect(statusRequest.url).toBe(
`/api/upgrade_assistant/ml_snapshots/${jobId}/${snapshotId}`
);
});
test('handles upgrade failure', async () => {
const { component, find } = testBed;
const upgradeButton: HTMLButtonElement | null = flyout!.querySelector(
'[data-test-subj="upgradeSnapshotButton"]'
);
const error = {
statusCode: 500,
error: 'Upgrade snapshot error',
message: 'Upgrade snapshot error',
};
httpRequestsMockHelpers.setUpgradeMlSnapshotResponse(undefined, error);
await act(async () => {
upgradeButton!.click();
});
component.update();
const upgradeRequest = server.requests[server.requests.length - 1];
expect(upgradeRequest.method).toBe('POST');
expect(upgradeRequest.url).toBe('/api/upgrade_assistant/ml_snapshots');
const accordionTestSubj = `depgroup_${upgradeStatusMockResponse.cluster[0].message
.split(' ')
.join('_')}`;
expect(find(`${accordionTestSubj}.fixMlSnapshotsButton`).text()).toEqual('Failed');
});
test('deletes snapshots', async () => {
const { component } = testBed;
const deleteButton: HTMLButtonElement | null = flyout!.querySelector(
'[data-test-subj="deleteSnapshotButton"]'
);
httpRequestsMockHelpers.setDeleteMlSnapshotResponse({
acknowledged: true,
});
await act(async () => {
deleteButton!.click();
});
component.update();
const request = server.requests[server.requests.length - 1];
const mlDeprecation = upgradeStatusMockResponse.cluster[0];
expect(request.method).toBe('DELETE');
expect(request.url).toBe(
`/api/upgrade_assistant/ml_snapshots/${
(mlDeprecation.correctiveAction! as MlAction).jobId
}/${(mlDeprecation.correctiveAction! as MlAction).snapshotId}`
);
});
test('handles delete failure', async () => {
const { component, find } = testBed;
const deleteButton: HTMLButtonElement | null = flyout!.querySelector(
'[data-test-subj="deleteSnapshotButton"]'
);
const error = {
statusCode: 500,
error: 'Upgrade snapshot error',
message: 'Upgrade snapshot error',
};
httpRequestsMockHelpers.setDeleteMlSnapshotResponse(undefined, error);
await act(async () => {
deleteButton!.click();
});
component.update();
const request = server.requests[server.requests.length - 1];
const mlDeprecation = upgradeStatusMockResponse.cluster[0];
expect(request.method).toBe('DELETE');
expect(request.url).toBe(
`/api/upgrade_assistant/ml_snapshots/${
(mlDeprecation.correctiveAction! as MlAction).jobId
}/${(mlDeprecation.correctiveAction! as MlAction).snapshotId}`
);
const accordionTestSubj = `depgroup_${upgradeStatusMockResponse.cluster[0].message
.split(' ')
.join('_')}`;
expect(find(`${accordionTestSubj}.fixMlSnapshotsButton`).text()).toEqual('Failed');
});
});
});
describe('no deprecations', () => {
beforeEach(async () => {
const noDeprecationsResponse = {
readyForUpgrade: false,
cluster: [],
indices: [],
};
httpRequestsMockHelpers.setLoadEsDeprecationsResponse(noDeprecationsResponse);
await act(async () => {
testBed = await setupClusterPage({ isReadOnlyMode: false });
});
const { component } = testBed;
component.update();
});
test('renders prompt', () => {
const { exists, find } = testBed;
expect(exists('noDeprecationsPrompt')).toBe(true);
expect(find('noDeprecationsPrompt').text()).toContain('Ready to upgrade!');
});
});
describe('error handling', () => {
test('handles 403', async () => {
const error = {
statusCode: 403,
error: 'Forbidden',
message: 'Forbidden',
};
httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error);
await act(async () => {
testBed = await setupClusterPage({ isReadOnlyMode: false });
});
const { component, exists, find } = testBed;
component.update();
expect(exists('permissionsError')).toBe(true);
expect(find('permissionsError').text()).toContain(
'You are not authorized to view Elasticsearch deprecations.'
);
});
test('shows upgraded message when all nodes have been upgraded', async () => {
const error = {
statusCode: 426,
error: 'Upgrade required',
message: 'There are some nodes running a different version of Elasticsearch',
attributes: {
// This is marked true in the scenario where none of the nodes have the same major version of Kibana,
// and therefore we assume all have been upgraded
allNodesUpgraded: true,
},
};
httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error);
await act(async () => {
testBed = await setupClusterPage({ isReadOnlyMode: false });
});
const { component, exists, find } = testBed;
component.update();
expect(exists('upgradedCallout')).toBe(true);
expect(find('upgradedCallout').text()).toContain(
'Your configuration is up to date. Kibana and all Elasticsearch nodes are running the same version.'
);
});
test('shows partially upgrade error when nodes are running different versions', async () => {
const error = {
statusCode: 426,
error: 'Upgrade required',
message: 'There are some nodes running a different version of Elasticsearch',
attributes: {
allNodesUpgraded: false,
},
};
httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error);
await act(async () => {
testBed = await setupClusterPage({ isReadOnlyMode: false });
});
const { component, exists, find } = testBed;
component.update();
expect(exists('partiallyUpgradedWarning')).toBe(true);
expect(find('partiallyUpgradedWarning').text()).toContain(
'Upgrade Kibana to the same version as your Elasticsearch cluster. One or more nodes in the cluster is running a different version than Kibana.'
);
});
test('handles generic error', async () => {
const error = {
statusCode: 500,
error: 'Internal server error',
message: 'Internal server error',
};
httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error);
await act(async () => {
testBed = await setupClusterPage({ isReadOnlyMode: false });
});
const { component, exists, find } = testBed;
component.update();
expect(exists('requestError')).toBe(true);
expect(find('requestError').text()).toContain(
'Could not retrieve Elasticsearch deprecations.'
);
});
});
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { registerTestBed, TestBed, TestBedConfig } from '@kbn/test/jest';
import { EsDeprecationsContent } from '../../../public/application/components/es_deprecations';
import { WithAppDependencies } from './setup_environment';
const testBedConfig: TestBedConfig = {
memoryRouter: {
initialEntries: ['/es_deprecations/cluster'],
componentRoutePath: '/es_deprecations/:tabName',
},
doMountAsync: true,
};
export type ClusterTestBed = TestBed<ClusterTestSubjects> & {
actions: ReturnType<typeof createActions>;
};
const createActions = (testBed: TestBed) => {
/**
* User Actions
*/
const clickTab = (tabName: string) => {
const { find } = testBed;
const camelcaseTabName = tabName.charAt(0).toUpperCase() + tabName.slice(1);
find(`upgradeAssistant${camelcaseTabName}Tab`).simulate('click');
};
const clickExpandAll = () => {
const { find } = testBed;
find('expandAll').simulate('click');
};
return {
clickTab,
clickExpandAll,
};
};
export const setup = async (overrides?: Record<string, unknown>): Promise<ClusterTestBed> => {
const initTestBed = registerTestBed(
WithAppDependencies(EsDeprecationsContent, overrides),
testBedConfig
);
const testBed = await initTestBed();
return {
...testBed,
actions: createActions(testBed),
};
};
export type ClusterTestSubjects =
| 'expandAll'
| 'deprecationsContainer'
| 'permissionsError'
| 'requestError'
| 'upgradedCallout'
| 'partiallyUpgradedWarning'
| 'noDeprecationsPrompt'
| string;

View file

@ -62,11 +62,35 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
]);
};
const setUpgradeMlSnapshotResponse = (response?: object, error?: ResponseError) => {
const status = error ? error.statusCode || 400 : 200;
const body = error ? error : response;
server.respondWith('POST', `${API_BASE_PATH}/ml_snapshots`, [
status,
{ 'Content-Type': 'application/json' },
JSON.stringify(body),
]);
};
const setDeleteMlSnapshotResponse = (response?: object, error?: ResponseError) => {
const status = error ? error.statusCode || 400 : 200;
const body = error ? error : response;
server.respondWith('DELETE', `${API_BASE_PATH}/ml_snapshots/:jobId/:snapshotId`, [
status,
{ 'Content-Type': 'application/json' },
JSON.stringify(body),
]);
};
return {
setLoadEsDeprecationsResponse,
setLoadDeprecationLoggingResponse,
setUpdateDeprecationLoggingResponse,
setUpdateIndexSettingsResponse,
setUpgradeMlSnapshotResponse,
setDeleteMlSnapshotResponse,
};
};

View file

@ -7,6 +7,7 @@
export { setup as setupOverviewPage, OverviewTestBed } from './overview.helpers';
export { setup as setupIndicesPage, IndicesTestBed } from './indices.helpers';
export { setup as setupClusterPage, ClusterTestBed } from './cluster.helpers';
export { setup as setupKibanaPage, KibanaTestBed } from './kibana.helpers';
export { setupEnvironment } from './setup_environment';

View file

@ -33,7 +33,6 @@ export const WithAppDependencies = (Comp: any, overrides: Record<string, unknown
const contextValue = {
http: (mockHttpClient as unknown) as HttpSetup,
isCloudEnabled: false,
docLinks: docLinksServiceMock.createStartContract(),
kibanaVersionInfo: {
currentMajor: mockKibanaSemverVersion.major,

View file

@ -7,7 +7,7 @@
import { act } from 'react-dom/test-utils';
import { indexSettingDeprecations } from '../../common/constants';
import { MIGRATION_DEPRECATION_LEVEL } from '../../common/types';
import { UpgradeAssistantStatus } from '../../common/types';
import { IndicesTestBed, setupIndicesPage, setupEnvironment } from './helpers';
@ -20,16 +20,19 @@ describe('Indices tab', () => {
});
describe('with deprecations', () => {
const upgradeStatusMockResponse = {
const upgradeStatusMockResponse: UpgradeAssistantStatus = {
readyForUpgrade: false,
cluster: [],
indices: [
{
level: 'warning' as MIGRATION_DEPRECATION_LEVEL,
level: 'warning',
message: indexSettingDeprecations.translog.deprecationMessage,
url: 'doc_url',
index: 'my_index',
deprecatedIndexSettings: indexSettingDeprecations.translog.settings,
correctiveAction: {
type: 'indexSetting',
deprecatedSettings: indexSettingDeprecations.translog.settings,
},
},
],
};
@ -56,6 +59,7 @@ describe('Indices tab', () => {
test('renders deprecations', () => {
const { exists, find } = testBed;
expect(exists('indexTabContent')).toBe(true);
expect(exists('deprecationsContainer')).toBe(true);
expect(find('indexCount').text()).toEqual('1');
});

View file

@ -38,7 +38,6 @@ describe('Overview page', () => {
details:
'translog retention settings [index.translog.retention.size] and [index.translog.retention.age] are ignored because translog is no longer used in peer recoveries with soft-deletes enabled (default in 7.0 or later)',
index: 'settings',
reindex: false,
},
],
};

View file

@ -28,7 +28,6 @@ export enum ReindexStatus {
}
export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation';
export interface QueueSettings extends SavedObjectAttributes {
/**
* A Unix timestamp of when the reindex operation was enqueued.
@ -190,11 +189,9 @@ export interface DeprecationAPIResponse {
node_settings: DeprecationInfo[];
index_settings: IndexSettingsDeprecationInfo;
}
export interface EnrichedDeprecationInfo extends DeprecationInfo {
index?: string;
node?: string;
reindex?: boolean;
deprecatedIndexSettings?: string[];
export interface ReindexAction {
type: 'reindex';
/**
* Indicate what blockers have been detected for calling reindex
* against this index.
@ -205,6 +202,21 @@ export interface EnrichedDeprecationInfo extends DeprecationInfo {
blockerForReindexing?: 'index-closed'; // 'index-closed' can be handled automatically, but requires more resources, user should be warned
}
export interface MlAction {
type: 'mlSnapshot';
snapshotId: string;
jobId: string;
}
export interface IndexSettingAction {
type: 'indexSetting';
deprecatedSettings: string[];
}
export interface EnrichedDeprecationInfo extends DeprecationInfo {
index?: string;
correctiveAction?: ReindexAction | MlAction | IndexSettingAction;
}
export interface UpgradeAssistantStatus {
readyForUpgrade: boolean;
cluster: EnrichedDeprecationInfo[];
@ -225,3 +237,11 @@ export interface ResolveIndexResponseFromES {
}>;
data_streams: Array<{ name: string; backing_indices: string[]; timestamp_field: string }>;
}
export const ML_UPGRADE_OP_TYPE = 'upgrade-assistant-ml-upgrade-operation';
export interface MlOperation extends SavedObjectAttributes {
nodeId: string;
snapshotId: string;
jobId: string;
}

View file

@ -5,6 +5,6 @@
"ui": true,
"configPath": ["xpack", "upgrade_assistant"],
"requiredPlugins": ["management", "licensing", "features"],
"optionalPlugins": ["cloud", "usageCollection"],
"optionalPlugins": ["usageCollection"],
"requiredBundles": ["esUiShared", "kibanaReact"]
}

View file

@ -24,7 +24,6 @@ export interface KibanaVersionContext {
export interface ContextValue {
http: HttpSetup;
isCloudEnabled: boolean;
docLinks: DocLinksStart;
kibanaVersionInfo: KibanaVersionContext;
notifications: NotificationsStart;

View file

@ -17,36 +17,84 @@ import {
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EnrichedDeprecationInfo } from '../../../../../common/types';
import {
EnrichedDeprecationInfo,
MlAction,
ReindexAction,
IndexSettingAction,
} from '../../../../../common/types';
import { AppContext } from '../../../app_context';
import { ReindexButton } from './reindex';
import { FixIndexSettingsButton } from './index_settings';
import { FixMlSnapshotsButton } from './ml_snapshots';
interface DeprecationCellProps {
items?: Array<{ title?: string; body: string }>;
indexName?: string;
reindex?: boolean;
deprecatedIndexSettings?: string[];
docUrl?: string;
headline?: string;
healthColor?: string;
children?: ReactNode;
reindexBlocker?: EnrichedDeprecationInfo['blockerForReindexing'];
correctiveAction?: EnrichedDeprecationInfo['correctiveAction'];
indexName?: string;
}
interface CellActionProps {
correctiveAction: EnrichedDeprecationInfo['correctiveAction'];
indexName?: string;
items: Array<{ title?: string; body: string }>;
}
const CellAction: FunctionComponent<CellActionProps> = ({ correctiveAction, indexName, items }) => {
const { type: correctiveActionType } = correctiveAction!;
switch (correctiveActionType) {
case 'mlSnapshot':
const { jobId, snapshotId } = correctiveAction as MlAction;
return (
<FixMlSnapshotsButton
jobId={jobId}
snapshotId={snapshotId}
// There will only ever be a single item for the cluster deprecations list, so we can use the index to access the first one
description={items[0]?.body}
/>
);
case 'reindex':
const { blockerForReindexing } = correctiveAction as ReindexAction;
return (
<AppContext.Consumer>
{({ http, docLinks }) => (
<ReindexButton
docLinks={docLinks}
reindexBlocker={blockerForReindexing}
indexName={indexName!}
http={http}
/>
)}
</AppContext.Consumer>
);
case 'indexSetting':
const { deprecatedSettings } = correctiveAction as IndexSettingAction;
return <FixIndexSettingsButton settings={deprecatedSettings} index={indexName!} />;
default:
throw new Error(`No UI defined for corrective action: ${correctiveActionType}`);
}
};
/**
* Used to display a deprecation with links to docs, a health indicator, and other descriptive information.
*/
export const DeprecationCell: FunctionComponent<DeprecationCellProps> = ({
headline,
healthColor,
correctiveAction,
indexName,
reindex,
deprecatedIndexSettings,
docUrl,
items = [],
children,
reindexBlocker,
}) => (
<div className="upgDeprecationCell">
<EuiFlexGroup responsive={false} wrap alignItems="baseline">
@ -84,24 +132,9 @@ export const DeprecationCell: FunctionComponent<DeprecationCellProps> = ({
)}
</EuiFlexItem>
{reindex && (
{correctiveAction && (
<EuiFlexItem grow={false}>
<AppContext.Consumer>
{({ http, docLinks }) => (
<ReindexButton
docLinks={docLinks}
reindexBlocker={reindexBlocker}
indexName={indexName!}
http={http}
/>
)}
</AppContext.Consumer>
</EuiFlexItem>
)}
{deprecatedIndexSettings?.length && (
<EuiFlexItem grow={false}>
<FixIndexSettingsButton settings={deprecatedIndexSettings} index={indexName!} />
<CellAction correctiveAction={correctiveAction} indexName={indexName} items={items} />
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -13,9 +13,9 @@ import { IndexDeprecationTableProps, IndexDeprecationTable } from './index_table
describe('IndexDeprecationTable', () => {
const defaultProps = {
indices: [
{ index: 'index1', details: 'Index 1 deets', reindex: true },
{ index: 'index2', details: 'Index 2 deets', reindex: true },
{ index: 'index3', details: 'Index 3 deets', reindex: true },
{ index: 'index1', details: 'Index 1 deets', correctiveAction: { type: 'reindex' } },
{ index: 'index2', details: 'Index 2 deets', correctiveAction: { type: 'reindex' } },
{ index: 'index3', details: 'Index 3 deets', correctiveAction: { type: 'reindex' } },
],
} as IndexDeprecationTableProps;
@ -49,19 +49,25 @@ describe('IndexDeprecationTable', () => {
items={
Array [
Object {
"correctiveAction": Object {
"type": "reindex",
},
"details": "Index 1 deets",
"index": "index1",
"reindex": true,
},
Object {
"correctiveAction": Object {
"type": "reindex",
},
"details": "Index 2 deets",
"index": "index2",
"reindex": true,
},
Object {
"correctiveAction": Object {
"type": "reindex",
},
"details": "Index 3 deets",
"index": "index3",
"reindex": true,
},
]
}

View file

@ -10,7 +10,11 @@ import React from 'react';
import { EuiBasicTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EnrichedDeprecationInfo } from '../../../../../common/types';
import {
EnrichedDeprecationInfo,
IndexSettingAction,
ReindexAction,
} from '../../../../../common/types';
import { AppContext } from '../../../app_context';
import { ReindexButton } from './reindex';
import { FixIndexSettingsButton } from './index_settings';
@ -19,9 +23,7 @@ const PAGE_SIZES = [10, 25, 50, 100, 250, 500, 1000];
export interface IndexDeprecationDetails {
index: string;
reindex: boolean;
deprecatedIndexSettings?: string[];
blockerForReindexing?: EnrichedDeprecationInfo['blockerForReindexing'];
correctiveAction?: EnrichedDeprecationInfo['correctiveAction'];
details?: string;
}
@ -152,9 +154,9 @@ export class IndexDeprecationTable extends React.Component<
// NOTE: this naive implementation assumes all indices in the table
// should show the reindex button or fix indices button. This should work for known use cases.
const { indices } = this.props;
const showReindexButton = Boolean(indices.find((i) => i.reindex === true));
const showReindexButton = Boolean(indices.find((i) => i.correctiveAction?.type === 'reindex'));
const showFixSettingsButton = Boolean(
indices.find((i) => i.deprecatedIndexSettings && i.deprecatedIndexSettings.length > 0)
indices.find((i) => i.correctiveAction?.type === 'indexSetting')
);
if (showReindexButton === false && showFixSettingsButton === false) {
@ -172,7 +174,9 @@ export class IndexDeprecationTable extends React.Component<
return (
<ReindexButton
docLinks={docLinks}
reindexBlocker={indexDep.blockerForReindexing}
reindexBlocker={
(indexDep.correctiveAction as ReindexAction).blockerForReindexing
}
indexName={indexDep.index!}
http={http}
/>
@ -184,7 +188,7 @@ export class IndexDeprecationTable extends React.Component<
return (
<FixIndexSettingsButton
settings={indexDep.deprecatedIndexSettings!}
settings={(indexDep.correctiveAction as IndexSettingAction).deprecatedSettings}
index={indexDep.index}
/>
);

View file

@ -72,18 +72,14 @@ describe('EsDeprecationList', () => {
indices={
Array [
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": undefined,
"correctiveAction": undefined,
"details": undefined,
"index": "0",
"reindex": false,
},
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": undefined,
"correctiveAction": undefined,
"details": undefined,
"index": "1",
"reindex": false,
},
]
}

View file

@ -32,12 +32,10 @@ const MessageDeprecation: FunctionComponent<{
return (
<DeprecationCell
reindexBlocker={deprecation.blockerForReindexing}
headline={deprecation.message}
healthColor={COLOR_MAP[deprecation.level]}
correctiveAction={deprecation.correctiveAction}
indexName={deprecation.index}
reindex={deprecation.reindex}
deprecatedIndexSettings={deprecation.deprecatedIndexSettings}
docUrl={deprecation.url}
items={items}
/>
@ -58,10 +56,10 @@ const SimpleMessageDeprecation: FunctionComponent<{ deprecation: EnrichedDepreca
return (
<DeprecationCell
reindexBlocker={deprecation.blockerForReindexing}
correctiveAction={deprecation.correctiveAction}
indexName={deprecation.index}
items={items}
docUrl={deprecation.url}
deprecatedIndexSettings={deprecation.deprecatedIndexSettings}
/>
);
};
@ -95,12 +93,11 @@ export const EsDeprecationList: FunctionComponent<{
if (currentGroupBy === GroupByOption.message && deprecations[0].index !== undefined) {
// We assume that every deprecation message is the same issue (since they have the same
// message) and that each deprecation will have an index associated with it.
const indices = deprecations.map((dep) => ({
index: dep.index!,
details: dep.details,
reindex: dep.reindex === true,
deprecatedIndexSettings: dep.deprecatedIndexSettings,
blockerForReindexing: dep.blockerForReindexing,
correctiveAction: dep.correctiveAction,
}));
return <IndexDeprecation indices={indices} deprecation={deprecations[0]} />;
} else if (currentGroupBy === GroupByOption.index) {

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { ButtonSize, EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FixSnapshotsFlyout } from './fix_snapshots_flyout';
import { useAppContext } from '../../../../app_context';
import { useSnapshotState } from './use_snapshot_state';
const i18nTexts = {
fixButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.fixButtonLabel',
{
defaultMessage: 'Fix',
}
),
upgradingButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.upgradingButtonLabel',
{
defaultMessage: 'Upgrading…',
}
),
deletingButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.deletingButtonLabel',
{
defaultMessage: 'Deleting…',
}
),
doneButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.doneButtonLabel',
{
defaultMessage: 'Done',
}
),
failedButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.failedButtonLabel',
{
defaultMessage: 'Failed',
}
),
};
interface Props {
snapshotId: string;
jobId: string;
description: string;
}
export const FixMlSnapshotsButton: React.FunctionComponent<Props> = ({
snapshotId,
jobId,
description,
}) => {
const { api } = useAppContext();
const { snapshotState, upgradeSnapshot, deleteSnapshot, updateSnapshotStatus } = useSnapshotState(
{
jobId,
snapshotId,
api,
}
);
const [showFlyout, setShowFlyout] = useState(false);
useEffect(() => {
updateSnapshotStatus();
}, [updateSnapshotStatus]);
const commonButtonProps = {
size: 's' as ButtonSize,
onClick: () => setShowFlyout(true),
'data-test-subj': 'fixMlSnapshotsButton',
};
let button = <EuiButton {...commonButtonProps}>{i18nTexts.fixButtonLabel}</EuiButton>;
switch (snapshotState.status) {
case 'in_progress':
button = (
<EuiButton color="secondary" {...commonButtonProps} isLoading>
{snapshotState.action === 'delete'
? i18nTexts.deletingButtonLabel
: i18nTexts.upgradingButtonLabel}
</EuiButton>
);
break;
case 'complete':
button = (
<EuiButton color="secondary" iconType="check" {...commonButtonProps} disabled>
{i18nTexts.doneButtonLabel}
</EuiButton>
);
break;
case 'error':
button = (
<EuiButton color="danger" iconType="cross" {...commonButtonProps}>
{i18nTexts.failedButtonLabel}
</EuiButton>
);
break;
}
return (
<>
{button}
{showFlyout && (
<FixSnapshotsFlyout
snapshotState={snapshotState}
upgradeSnapshot={upgradeSnapshot}
deleteSnapshot={deleteSnapshot}
description={description}
closeFlyout={() => setShowFlyout(false)}
/>
)}
</>
);
};

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiPortal,
EuiTitle,
EuiText,
EuiCallOut,
EuiSpacer,
} from '@elastic/eui';
import { SnapshotStatus } from './use_snapshot_state';
import { ResponseError } from '../../../../lib/api';
interface SnapshotState extends SnapshotStatus {
error?: ResponseError;
}
interface Props {
upgradeSnapshot: () => Promise<void>;
deleteSnapshot: () => Promise<void>;
description: string;
closeFlyout: () => void;
snapshotState: SnapshotState;
}
const i18nTexts = {
upgradeButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.upgradeButtonLabel',
{
defaultMessage: 'Upgrade',
}
),
retryUpgradeButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.retryUpgradeButtonLabel',
{
defaultMessage: 'Retry upgrade',
}
),
closeButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.cancelButtonLabel',
{
defaultMessage: 'Close',
}
),
deleteButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.deleteButtonLabel',
{
defaultMessage: 'Delete',
}
),
retryDeleteButtonLabel: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.retryDeleteButtonLabel',
{
defaultMessage: 'Retry delete',
}
),
flyoutTitle: i18n.translate('xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.title', {
defaultMessage: 'Upgrade or delete model snapshot',
}),
deleteSnapshotErrorTitle: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.deleteSnapshotErrorTitle',
{
defaultMessage: 'Error deleting snapshot',
}
),
upgradeSnapshotErrorTitle: i18n.translate(
'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.upgradeSnapshotErrorTitle',
{
defaultMessage: 'Error upgrading snapshot',
}
),
};
export const FixSnapshotsFlyout = ({
upgradeSnapshot,
deleteSnapshot,
description,
closeFlyout,
snapshotState,
}: Props) => {
const onUpgradeSnapshot = () => {
upgradeSnapshot();
closeFlyout();
};
const onDeleteSnapshot = () => {
deleteSnapshot();
closeFlyout();
};
return (
<EuiPortal>
<EuiFlyout
onClose={closeFlyout}
ownFocus
size="m"
maxWidth
data-test-subj="fixSnapshotsFlyout"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2>{i18nTexts.flyoutTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{snapshotState.error && (
<>
<EuiCallOut
title={
snapshotState.action === 'delete'
? i18nTexts.deleteSnapshotErrorTitle
: i18nTexts.upgradeSnapshotErrorTitle
}
color="danger"
iconType="alert"
data-test-subj="upgradeSnapshotError"
>
{snapshotState.error.message}
</EuiCallOut>
<EuiSpacer />
</>
)}
<EuiText>
<p>{description}</p>
</EuiText>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left">
{i18nTexts.closeButtonLabel}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="deleteSnapshotButton"
color="danger"
onClick={onDeleteSnapshot}
isLoading={false}
>
{snapshotState.action === 'delete' && snapshotState.error
? i18nTexts.retryDeleteButtonLabel
: i18nTexts.deleteButtonLabel}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
fill
onClick={onUpgradeSnapshot}
isLoading={false}
data-test-subj="upgradeSnapshotButton"
>
{snapshotState.action === 'upgrade' && snapshotState.error
? i18nTexts.retryUpgradeButtonLabel
: i18nTexts.upgradeButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { FixMlSnapshotsButton } from './button';

View file

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useRef, useCallback, useState, useEffect } from 'react';
import { ApiService, ResponseError } from '../../../../lib/api';
const POLL_INTERVAL_MS = 1000;
export interface SnapshotStatus {
snapshotId: string;
jobId: string;
status: 'complete' | 'in_progress' | 'error' | 'idle';
action?: 'upgrade' | 'delete';
}
export const useSnapshotState = ({
jobId,
snapshotId,
api,
}: {
jobId: string;
snapshotId: string;
api: ApiService;
}) => {
const [requestError, setRequestError] = useState<ResponseError | undefined>(undefined);
const [snapshotState, setSnapshotState] = useState<SnapshotStatus>({
status: 'idle',
jobId,
snapshotId,
});
const pollIntervalIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMounted = useRef(false);
const clearPollInterval = useCallback(() => {
if (pollIntervalIdRef.current) {
clearTimeout(pollIntervalIdRef.current);
pollIntervalIdRef.current = null;
}
}, []);
const updateSnapshotStatus = useCallback(async () => {
clearPollInterval();
const { data, error: updateStatusError } = await api.getMlSnapshotUpgradeStatus({
jobId,
snapshotId,
});
if (updateStatusError) {
setSnapshotState({
snapshotId,
jobId,
action: 'upgrade',
status: 'error',
});
setRequestError(updateStatusError);
return;
}
setSnapshotState(data);
// Only keep polling if it exists and is in progress.
if (data?.status === 'in_progress') {
pollIntervalIdRef.current = setTimeout(updateSnapshotStatus, POLL_INTERVAL_MS);
}
}, [api, clearPollInterval, jobId, snapshotId]);
const upgradeSnapshot = useCallback(async () => {
setSnapshotState({
snapshotId,
jobId,
action: 'upgrade',
status: 'in_progress',
});
const { data, error: upgradeError } = await api.upgradeMlSnapshot({ jobId, snapshotId });
if (upgradeError) {
setRequestError(upgradeError);
setSnapshotState({
snapshotId,
jobId,
action: 'upgrade',
status: 'error',
});
return;
}
setSnapshotState(data);
updateSnapshotStatus();
}, [api, jobId, snapshotId, updateSnapshotStatus]);
const deleteSnapshot = useCallback(async () => {
setSnapshotState({
snapshotId,
jobId,
action: 'delete',
status: 'in_progress',
});
const { error: deleteError } = await api.deleteMlSnapshot({
snapshotId,
jobId,
});
if (deleteError) {
setRequestError(deleteError);
setSnapshotState({
snapshotId,
jobId,
action: 'delete',
status: 'error',
});
return;
}
setSnapshotState({
snapshotId,
jobId,
action: 'delete',
status: 'complete',
});
}, [api, jobId, snapshotId]);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
// Clean up on unmount.
clearPollInterval();
};
}, [clearPollInterval]);
return {
snapshotState: {
...snapshotState,
error: requestError,
},
upgradeSnapshot,
updateSnapshotStatus,
deleteSnapshot,
};
};

View file

@ -14,11 +14,7 @@ import { EuiButton, EuiLoadingSpinner, EuiText, EuiToolTip } from '@elastic/eui'
import { FormattedMessage } from '@kbn/i18n/react';
import { DocLinksStart, HttpSetup } from 'src/core/public';
import { API_BASE_PATH } from '../../../../../../common/constants';
import {
EnrichedDeprecationInfo,
ReindexStatus,
UIReindexOption,
} from '../../../../../../common/types';
import { ReindexAction, ReindexStatus, UIReindexOption } from '../../../../../../common/types';
import { LoadingState } from '../../../types';
import { ReindexFlyout } from './flyout';
import { ReindexPollingService, ReindexState } from './polling_service';
@ -27,7 +23,7 @@ interface ReindexButtonProps {
indexName: string;
http: HttpSetup;
docLinks: DocLinksStart;
reindexBlocker?: EnrichedDeprecationInfo['blockerForReindexing'];
reindexBlocker?: ReindexAction['blockerForReindexing'];
}
interface ReindexButtonState {

View file

@ -21,7 +21,7 @@ import {
EuiTitle,
} from '@elastic/eui';
import { EnrichedDeprecationInfo, ReindexStatus } from '../../../../../../../common/types';
import { ReindexAction, ReindexStatus } from '../../../../../../../common/types';
import { ReindexState } from '../polling_service';
import { ChecklistFlyoutStep } from './checklist_step';
@ -40,7 +40,7 @@ interface ReindexFlyoutProps {
startReindex: () => void;
cancelReindex: () => void;
docLinks: DocLinksStart;
reindexBlocker?: EnrichedDeprecationInfo['blockerForReindexing'];
reindexBlocker?: ReindexAction['blockerForReindexing'];
}
interface ReindexFlyoutState {

View file

@ -90,6 +90,38 @@ export class ApiService {
return result;
}
public async upgradeMlSnapshot(body: { jobId: string; snapshotId: string }) {
const result = await this.sendRequest({
path: `${API_BASE_PATH}/ml_snapshots`,
method: 'post',
body,
});
return result;
}
public async deleteMlSnapshot({ jobId, snapshotId }: { jobId: string; snapshotId: string }) {
const result = await this.sendRequest({
path: `${API_BASE_PATH}/ml_snapshots/${jobId}/${snapshotId}`,
method: 'delete',
});
return result;
}
public async getMlSnapshotUpgradeStatus({
jobId,
snapshotId,
}: {
jobId: string;
snapshotId: string;
}) {
return await this.sendRequest({
path: `${API_BASE_PATH}/ml_snapshots/${jobId}/${snapshotId}`,
method: 'get',
});
}
}
export const apiService = new ApiService();

View file

@ -14,7 +14,6 @@ import { breadcrumbService } from './lib/breadcrumbs';
export async function mountManagementSection(
coreSetup: CoreSetup,
isCloudEnabled: boolean,
params: ManagementAppMountParams,
kibanaVersionInfo: KibanaVersionContext,
readonly: boolean
@ -31,7 +30,6 @@ export async function mountManagementSection(
return renderApp({
element,
isCloudEnabled,
http,
i18n,
docLinks,

View file

@ -9,19 +9,17 @@ import SemVer from 'semver/classes/semver';
import { i18n } from '@kbn/i18n';
import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public';
import { CloudSetup } from '../../cloud/public';
import { ManagementSetup } from '../../../../src/plugins/management/public';
import { Config } from '../common/config';
interface Dependencies {
cloud: CloudSetup;
management: ManagementSetup;
}
export class UpgradeAssistantUIPlugin implements Plugin {
constructor(private ctx: PluginInitializerContext) {}
setup(coreSetup: CoreSetup, { cloud, management }: Dependencies) {
setup(coreSetup: CoreSetup, { management }: Dependencies) {
const { enabled, readonly } = this.ctx.config.get<Config>();
if (!enabled) {
@ -29,7 +27,6 @@ export class UpgradeAssistantUIPlugin implements Plugin {
}
const appRegistrar = management.sections.section.stack;
const isCloudEnabled = Boolean(cloud?.isCloudEnabled);
const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version);
const kibanaVersionInfo = {
@ -59,7 +56,6 @@ export class UpgradeAssistantUIPlugin implements Plugin {
const { mountManagementSection } = await import('./application/mount_management_section');
const unmountAppCallback = await mountManagementSection(
coreSetup,
isCloudEnabled,
params,
kibanaVersionInfo,
readonly

View file

@ -19,6 +19,12 @@
"message": "Datafeed [deprecation-datafeed] uses deprecated query options",
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-7.0.html#breaking_70_search_changes",
"details": "[Deprecated field [use_dis_max] used, replaced by [Set [tie_breaker] to 1 instead]]"
},
{
"level": "critical",
"message": "model snapshot [1] for job [deprecation_check_job] needs to be deleted or upgraded",
"url": "",
"details": "details"
}
],
"node_settings": [
@ -46,6 +52,33 @@
"details": "[[type: tweet, field: liked]]"
}
],
"old_index": [
{
"level": "critical",
"message": "Index created before 7.0",
"url":
"https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html",
"details": "This index was created using version: 6.8.13"
}
],
"closed_index": [
{
"level": "critical",
"message": "Index created before 7.0",
"url": "https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html",
"details": "This index was created using version: 6.8.13"
}
],
"deprecated_settings": [
{
"level": "warning",
"message": "translog retention settings are ignored",
"url":
"https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-translog.html",
"details":
"translog retention settings [index.translog.retention.size] and [index.translog.retention.age] are ignored because translog is no longer used in peer recoveries with soft-deletes enabled (default in 7.0 or later)"
}
],
".kibana": [
{
"level": "warning",
@ -79,4 +112,4 @@
}
]
}
}
}

View file

@ -4,24 +4,39 @@ exports[`getUpgradeAssistantStatus returns the correct shape of data 1`] = `
Object {
"cluster": Array [
Object {
"correctiveAction": undefined,
"details": "templates using \`template\` field: security_audit_log,watches,.monitoring-alerts,triggered_watches,.ml-anomalies-,.ml-notifications,.ml-meta,.monitoring-kibana,.monitoring-es,.monitoring-logstash,.watch-history-6,.ml-state,security-index-template",
"level": "warning",
"message": "Template patterns are no longer using \`template\` field, but \`index_patterns\` instead",
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_indices_changes.html#_index_templates_use_literal_index_patterns_literal_instead_of_literal_template_literal",
},
Object {
"correctiveAction": undefined,
"details": "{.monitoring-logstash=[Coercion of boolean fields], .monitoring-es=[Coercion of boolean fields], .ml-anomalies-=[Coercion of boolean fields], .watch-history-6=[Coercion of boolean fields], .monitoring-kibana=[Coercion of boolean fields], security-index-template=[Coercion of boolean fields]}",
"level": "warning",
"message": "one or more templates use deprecated mapping settings",
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_indices_changes.html",
},
Object {
"correctiveAction": undefined,
"details": "[Deprecated field [use_dis_max] used, replaced by [Set [tie_breaker] to 1 instead]]",
"level": "warning",
"message": "Datafeed [deprecation-datafeed] uses deprecated query options",
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-7.0.html#breaking_70_search_changes",
},
Object {
"correctiveAction": Object {
"jobId": "deprecation_check_job",
"snapshotId": "1",
"type": "mlSnapshot",
},
"details": "details",
"level": "critical",
"message": "model snapshot [1] for job [deprecation_check_job] needs to be deleted or upgraded",
"url": "",
},
Object {
"correctiveAction": undefined,
"details": "This node thing is wrong",
"level": "critical",
"message": "A node-level issue",
@ -30,63 +45,87 @@ Object {
],
"indices": Array [
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": Array [],
"correctiveAction": undefined,
"details": "[[type: doc, field: spins], [type: doc, field: mlockall], [type: doc, field: node_master], [type: doc, field: primary]]",
"index": ".monitoring-es-6-2018.11.07",
"level": "warning",
"message": "Coercion of boolean fields",
"reindex": false,
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields",
},
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": Array [],
"correctiveAction": undefined,
"details": "[[type: tweet, field: liked]]",
"index": "twitter",
"level": "warning",
"message": "Coercion of boolean fields",
"reindex": false,
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields",
},
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": Array [],
"correctiveAction": Object {
"blockerForReindexing": undefined,
"type": "reindex",
},
"details": "This index was created using version: 6.8.13",
"index": "old_index",
"level": "critical",
"message": "Index created before 7.0",
"url": "https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html",
},
Object {
"correctiveAction": Object {
"blockerForReindexing": "index-closed",
"type": "reindex",
},
"details": "This index was created using version: 6.8.13",
"index": "closed_index",
"level": "critical",
"message": "Index created before 7.0",
"url": "https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html",
},
Object {
"correctiveAction": Object {
"deprecatedSettings": Array [
"translog.retention.size",
"translog.retention.age",
],
"type": "indexSetting",
},
"details": "translog retention settings [index.translog.retention.size] and [index.translog.retention.age] are ignored because translog is no longer used in peer recoveries with soft-deletes enabled (default in 7.0 or later)",
"index": "deprecated_settings",
"level": "warning",
"message": "translog retention settings are ignored",
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-translog.html",
},
Object {
"correctiveAction": undefined,
"details": "[[type: index-pattern, field: notExpandable], [type: config, field: xPackMonitoring:allowReport], [type: config, field: xPackMonitoring:showBanner], [type: dashboard, field: pause], [type: dashboard, field: timeRestore]]",
"index": ".kibana",
"level": "warning",
"message": "Coercion of boolean fields",
"reindex": false,
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields",
},
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": Array [],
"correctiveAction": undefined,
"details": "[[type: doc, field: notify], [type: doc, field: created], [type: doc, field: attach_payload], [type: doc, field: met]]",
"index": ".watcher-history-6-2018.11.07",
"level": "warning",
"message": "Coercion of boolean fields",
"reindex": false,
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields",
},
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": Array [],
"correctiveAction": undefined,
"details": "[[type: doc, field: snapshot]]",
"index": ".monitoring-kibana-6-2018.11.07",
"level": "warning",
"message": "Coercion of boolean fields",
"reindex": false,
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields",
},
Object {
"blockerForReindexing": undefined,
"deprecatedIndexSettings": Array [],
"correctiveAction": undefined,
"details": "[[type: tweet, field: liked]]",
"index": "twitter2",
"level": "warning",
"message": "Coercion of boolean fields",
"reindex": false,
"url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields",
},
],

View file

@ -22,7 +22,13 @@ const asApiResponse = <T>(body: T): RequestEvent<T> =>
describe('getUpgradeAssistantStatus', () => {
const resolvedIndices = {
indices: fakeIndexNames.map((f) => ({ name: f, attributes: ['open'] })),
indices: fakeIndexNames.map((indexName) => {
// mark one index as closed to test blockerForReindexing flag
if (indexName === 'closed_index') {
return { name: indexName, attributes: ['closed'] };
}
return { name: indexName, attributes: ['open'] };
}),
};
// @ts-expect-error mock data is too loosely typed
@ -39,12 +45,12 @@ describe('getUpgradeAssistantStatus', () => {
esClient.asCurrentUser.indices.resolveIndex.mockResolvedValue(asApiResponse(resolvedIndices));
it('calls /_migration/deprecations', async () => {
await getUpgradeAssistantStatus(esClient, false);
await getUpgradeAssistantStatus(esClient);
expect(esClient.asCurrentUser.migration.deprecations).toHaveBeenCalled();
});
it('returns the correct shape of data', async () => {
const resp = await getUpgradeAssistantStatus(esClient, false);
const resp = await getUpgradeAssistantStatus(esClient);
expect(resp).toMatchSnapshot();
});
@ -59,7 +65,7 @@ describe('getUpgradeAssistantStatus', () => {
})
);
await expect(getUpgradeAssistantStatus(esClient, false)).resolves.toHaveProperty(
await expect(getUpgradeAssistantStatus(esClient)).resolves.toHaveProperty(
'readyForUpgrade',
false
);
@ -76,32 +82,9 @@ describe('getUpgradeAssistantStatus', () => {
})
);
await expect(getUpgradeAssistantStatus(esClient, false)).resolves.toHaveProperty(
await expect(getUpgradeAssistantStatus(esClient)).resolves.toHaveProperty(
'readyForUpgrade',
true
);
});
it('filters out security realm deprecation on Cloud', async () => {
esClient.asCurrentUser.migration.deprecations.mockResolvedValue(
// @ts-expect-error not full interface
asApiResponse({
cluster_settings: [
{
level: 'critical',
message: 'Security realm settings structure changed',
url: 'https://...',
},
],
node_settings: [],
ml_settings: [],
index_settings: {},
})
);
const result = await getUpgradeAssistantStatus(esClient, true);
expect(result).toHaveProperty('readyForUpgrade', true);
expect(result).toHaveProperty('cluster', []);
});
});

View file

@ -16,26 +16,12 @@ import {
import { esIndicesStateCheck } from './es_indices_state_check';
export async function getUpgradeAssistantStatus(
dataClient: IScopedClusterClient,
isCloudEnabled: boolean
dataClient: IScopedClusterClient
): Promise<UpgradeAssistantStatus> {
const { body: deprecations } = await dataClient.asCurrentUser.migration.deprecations();
const cluster = getClusterDeprecations(deprecations, isCloudEnabled);
const indices = getCombinedIndexInfos(deprecations);
const indexNames = indices.map(({ index }) => index!);
// If we have found deprecation information for index/indices check whether the index is
// open or closed.
if (indexNames.length) {
const indexStates = await esIndicesStateCheck(dataClient.asCurrentUser, indexNames);
indices.forEach((indexData) => {
indexData.blockerForReindexing =
indexStates[indexData.index!] === 'closed' ? 'index-closed' : undefined;
});
}
const cluster = getClusterDeprecations(deprecations);
const indices = await getCombinedIndexInfos(deprecations, dataClient);
const criticalWarnings = cluster.concat(indices).filter((d) => d.level === 'critical');
@ -47,38 +33,91 @@ export async function getUpgradeAssistantStatus(
}
// Reformats the index deprecations to an array of deprecation warnings extended with an index field.
const getCombinedIndexInfos = (deprecations: DeprecationAPIResponse) =>
Object.keys(deprecations.index_settings).reduce((indexDeprecations, indexName) => {
return indexDeprecations.concat(
deprecations.index_settings[indexName].map(
(d) =>
({
...d,
index: indexName,
reindex: /Index created before/.test(d.message),
deprecatedIndexSettings: getIndexSettingDeprecations(d.message),
} as EnrichedDeprecationInfo)
)
);
}, [] as EnrichedDeprecationInfo[]);
const getCombinedIndexInfos = async (
deprecations: DeprecationAPIResponse,
dataClient: IScopedClusterClient
) => {
const indices = Object.keys(deprecations.index_settings).reduce(
(indexDeprecations, indexName) => {
return indexDeprecations.concat(
deprecations.index_settings[indexName].map(
(d) =>
({
...d,
index: indexName,
correctiveAction: getCorrectiveAction(d.message),
} as EnrichedDeprecationInfo)
)
);
},
[] as EnrichedDeprecationInfo[]
);
const getClusterDeprecations = (deprecations: DeprecationAPIResponse, isCloudEnabled: boolean) => {
const combined = deprecations.cluster_settings
const indexNames = indices.map(({ index }) => index!);
// If we have found deprecation information for index/indices
// check whether the index is open or closed.
if (indexNames.length) {
const indexStates = await esIndicesStateCheck(dataClient.asCurrentUser, indexNames);
indices.forEach((indexData) => {
if (indexData.correctiveAction?.type === 'reindex') {
indexData.correctiveAction.blockerForReindexing =
indexStates[indexData.index!] === 'closed' ? 'index-closed' : undefined;
}
});
}
return indices as EnrichedDeprecationInfo[];
};
const getClusterDeprecations = (deprecations: DeprecationAPIResponse) => {
const combinedDeprecations = deprecations.cluster_settings
.concat(deprecations.ml_settings)
.concat(deprecations.node_settings);
if (isCloudEnabled) {
// In Cloud, this is changed at upgrade time. Filter it out to improve upgrade UX.
return combined.filter((d) => d.message !== 'Security realm settings structure changed');
} else {
return combined;
}
return combinedDeprecations.map((deprecation) => {
return {
...deprecation,
correctiveAction: getCorrectiveAction(deprecation.message),
};
}) as EnrichedDeprecationInfo[];
};
const getIndexSettingDeprecations = (message: string) => {
const indexDeprecation = Object.values(indexSettingDeprecations).find(
const getCorrectiveAction = (message: string) => {
const indexSettingDeprecation = Object.values(indexSettingDeprecations).find(
({ deprecationMessage }) => deprecationMessage === message
);
const requiresReindexAction = /Index created before/.test(message);
const requiresIndexSettingsAction = Boolean(indexSettingDeprecation);
const requiresMlAction = /model snapshot/.test(message);
return indexDeprecation?.settings || [];
if (requiresReindexAction) {
return {
type: 'reindex',
};
}
if (requiresIndexSettingsAction) {
return {
type: 'indexSetting',
deprecatedSettings: indexSettingDeprecation!.settings,
};
}
if (requiresMlAction) {
// This logic is brittle, as we are expecting the message to be in a particular format to extract the snapshot ID and job ID
// Implementing https://github.com/elastic/elasticsearch/issues/73089 in ES should address this concern
const regex = /(?<=\[).*?(?=\])/g;
const matches = message.match(regex);
if (matches?.length === 2) {
return {
type: 'mlSnapshot',
snapshotId: matches[0],
jobId: matches[1],
};
}
}
return undefined;
};

View file

@ -17,7 +17,6 @@ import {
SavedObjectsServiceStart,
} from '../../../../src/core/server';
import { CloudSetup } from '../../cloud/server';
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
import { LicensingPluginSetup } from '../../licensing/server';
@ -25,12 +24,13 @@ import { CredentialStore, credentialStoreFactory } from './lib/reindexing/creden
import { ReindexWorker } from './lib/reindexing';
import { registerUpgradeAssistantUsageCollector } from './lib/telemetry';
import { versionService } from './lib/version';
import { registerClusterCheckupRoutes } from './routes/cluster_checkup';
import { registerDeprecationLoggingRoutes } from './routes/deprecation_logging';
import { registerReindexIndicesRoutes, createReindexWorker } from './routes/reindex_indices';
import { registerTelemetryRoutes } from './routes/telemetry';
import { registerUpdateSettingsRoute } from './routes/update_index_settings';
import { telemetrySavedObjectType, reindexOperationSavedObjectType } from './saved_object_types';
import { createReindexWorker } from './routes/reindex_indices';
import { registerRoutes } from './routes/register_routes';
import {
telemetrySavedObjectType,
reindexOperationSavedObjectType,
mlSavedObjectType,
} from './saved_object_types';
import { RouteDependencies } from './types';
@ -38,7 +38,6 @@ interface PluginsSetup {
usageCollection: UsageCollectionSetup;
licensing: LicensingPluginSetup;
features: FeaturesPluginSetup;
cloud?: CloudSetup;
}
export class UpgradeAssistantServerPlugin implements Plugin {
@ -68,12 +67,13 @@ export class UpgradeAssistantServerPlugin implements Plugin {
setup(
{ http, getStartServices, capabilities, savedObjects }: CoreSetup,
{ usageCollection, cloud, features, licensing }: PluginsSetup
{ usageCollection, features, licensing }: PluginsSetup
) {
this.licensing = licensing;
savedObjects.registerType(reindexOperationSavedObjectType);
savedObjects.registerType(telemetrySavedObjectType);
savedObjects.registerType(mlSavedObjectType);
features.registerElasticsearchFeature({
id: 'upgrade_assistant',
@ -91,7 +91,6 @@ export class UpgradeAssistantServerPlugin implements Plugin {
const router = http.createRouter();
const dependencies: RouteDependencies = {
cloud,
router,
credentialStore: this.credentialStore,
log: this.logger,
@ -107,12 +106,7 @@ export class UpgradeAssistantServerPlugin implements Plugin {
// Initialize version service with current kibana version
versionService.setup(this.kibanaVersion);
registerClusterCheckupRoutes(dependencies);
registerDeprecationLoggingRoutes(dependencies);
registerReindexIndicesRoutes(dependencies, this.getWorker.bind(this));
// Bootstrap the needed routes and the collector for the telemetry
registerTelemetryRoutes(dependencies);
registerUpdateSettingsRoute(dependencies);
registerRoutes(dependencies, this.getWorker.bind(this));
if (usageCollection) {
getStartServices().then(([{ savedObjects: savedObjectsService, elasticsearch }]) => {

View file

@ -49,6 +49,7 @@ export const createMockRouter = () => {
post: assign('post'),
put: assign('put'),
patch: assign('patch'),
delete: assign('delete'),
};
};

View file

@ -32,9 +32,6 @@ describe('cluster checkup API', () => {
beforeEach(() => {
mockRouter = createMockRouter();
routeDependencies = {
cloud: {
isCloudEnabled: true,
},
router: mockRouter,
};
registerClusterCheckupRoutes(routeDependencies);
@ -44,24 +41,6 @@ describe('cluster checkup API', () => {
jest.resetAllMocks();
});
describe('with cloud enabled', () => {
it('is provided to getUpgradeAssistantStatus', async () => {
const spy = jest.spyOn(MigrationApis, 'getUpgradeAssistantStatus');
MigrationApis.getUpgradeAssistantStatus.mockResolvedValue({
cluster: [],
indices: [],
nodes: [],
});
await routeDependencies.router.getHandler({
method: 'get',
pathPattern: '/api/upgrade_assistant/status',
})(routeHandlerContextMock, createRequestMock(), kibanaResponseFactory);
expect(spy.mock.calls[0][1]).toBe(true);
});
});
describe('GET /api/upgrade_assistant/reindex/{indexName}.json', () => {
it('returns state', async () => {
MigrationApis.getUpgradeAssistantStatus.mockResolvedValue({

View file

@ -12,9 +12,7 @@ import { RouteDependencies } from '../types';
import { reindexActionsFactory } from '../lib/reindexing/reindex_actions';
import { reindexServiceFactory } from '../lib/reindexing';
export function registerClusterCheckupRoutes({ cloud, router, licensing, log }: RouteDependencies) {
const isCloudEnabled = Boolean(cloud?.isCloudEnabled);
export function registerClusterCheckupRoutes({ router, licensing, log }: RouteDependencies) {
router.get(
{
path: `${API_BASE_PATH}/status`,
@ -32,7 +30,7 @@ export function registerClusterCheckupRoutes({ cloud, router, licensing, log }:
response
) => {
try {
const status = await getUpgradeAssistantStatus(client, isCloudEnabled);
const status = await getUpgradeAssistantStatus(client);
const asCurrentUser = client.asCurrentUser;
const reindexActions = reindexActionsFactory(savedObjectsClient, asCurrentUser);

View file

@ -0,0 +1,365 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kibanaResponseFactory, RequestHandler } from 'src/core/server';
import { createMockRouter, MockRouter, routeHandlerContextMock } from './__mocks__/routes.mock';
import { createRequestMock } from './__mocks__/request.mock';
import { registerMlSnapshotRoutes } from './ml_snapshots';
jest.mock('../lib/es_version_precheck', () => ({
versionCheckHandlerWrapper: <P, Q, B>(handler: RequestHandler<P, Q, B>) => handler,
}));
const JOB_ID = 'job_id';
const SNAPSHOT_ID = 'snapshot_id';
const NODE_ID = 'node_id';
describe('ML snapshots APIs', () => {
let mockRouter: MockRouter;
let routeDependencies: any;
beforeEach(() => {
mockRouter = createMockRouter();
routeDependencies = {
router: mockRouter,
};
registerMlSnapshotRoutes(routeDependencies);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('POST /api/upgrade_assistant/ml_snapshots', () => {
it('returns 200 status and in_progress status', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.upgradeJobSnapshot as jest.Mock).mockResolvedValue({
body: {
node: NODE_ID,
completed: false,
},
});
const resp = await routeDependencies.router.getHandler({
method: 'post',
pathPattern: '/api/upgrade_assistant/ml_snapshots',
})(
routeHandlerContextMock,
createRequestMock({
body: {
snapshotId: SNAPSHOT_ID,
jobId: JOB_ID,
},
}),
kibanaResponseFactory
);
expect(resp.status).toEqual(200);
expect(resp.payload).toEqual({
jobId: JOB_ID,
nodeId: NODE_ID,
snapshotId: SNAPSHOT_ID,
status: 'in_progress',
});
});
it('returns 200 status and complete status', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.upgradeJobSnapshot as jest.Mock).mockResolvedValue({
body: {
node: NODE_ID,
completed: true,
},
});
const resp = await routeDependencies.router.getHandler({
method: 'post',
pathPattern: '/api/upgrade_assistant/ml_snapshots',
})(
routeHandlerContextMock,
createRequestMock({
body: {
snapshotId: SNAPSHOT_ID,
jobId: JOB_ID,
},
}),
kibanaResponseFactory
);
expect(resp.status).toEqual(200);
expect(resp.payload).toEqual({
jobId: JOB_ID,
nodeId: NODE_ID,
snapshotId: SNAPSHOT_ID,
status: 'complete',
});
});
it('returns an error if it throws', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.upgradeJobSnapshot as jest.Mock).mockRejectedValue(new Error('scary error!'));
await expect(
routeDependencies.router.getHandler({
method: 'post',
pathPattern: '/api/upgrade_assistant/ml_snapshots',
})(
routeHandlerContextMock,
createRequestMock({
body: {
snapshotId: SNAPSHOT_ID,
jobId: JOB_ID,
},
}),
kibanaResponseFactory
)
).rejects.toThrow('scary error!');
});
});
describe('DELETE /api/upgrade_assistant/ml_snapshots/:jobId/:snapshotId', () => {
it('returns 200 status', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.deleteModelSnapshot as jest.Mock).mockResolvedValue({
body: { acknowledged: true },
});
const resp = await routeDependencies.router.getHandler({
method: 'delete',
pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}',
})(
routeHandlerContextMock,
createRequestMock({
params: { snapshotId: 'snapshot_id1', jobId: 'job_id1' },
}),
kibanaResponseFactory
);
expect(resp.status).toEqual(200);
expect(resp.payload).toEqual({
acknowledged: true,
});
});
it('returns an error if it throws', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.deleteModelSnapshot as jest.Mock).mockRejectedValue(new Error('scary error!'));
await expect(
routeDependencies.router.getHandler({
method: 'delete',
pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}',
})(
routeHandlerContextMock,
createRequestMock({
params: { snapshotId: 'snapshot_id1', jobId: 'job_id1' },
}),
kibanaResponseFactory
)
).rejects.toThrow('scary error!');
});
});
describe('GET /api/upgrade_assistant/ml_snapshots/:jobId/:snapshotId', () => {
it('returns "idle" status if saved object does not exist', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.getModelSnapshots as jest.Mock).mockResolvedValue({
body: {
count: 1,
model_snapshots: [
{
job_id: JOB_ID,
min_version: '6.4.0',
timestamp: 1575402237000,
description: 'State persisted due to job close at 2019-12-03T19:43:57+0000',
snapshot_id: SNAPSHOT_ID,
snapshot_doc_count: 1,
model_size_stats: {},
latest_record_time_stamp: 1576971072000,
latest_result_time_stamp: 1576965600000,
retain: false,
},
],
},
});
const resp = await routeDependencies.router.getHandler({
method: 'get',
pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}',
})(
routeHandlerContextMock,
createRequestMock({
params: {
snapshotId: SNAPSHOT_ID,
jobId: JOB_ID,
},
}),
kibanaResponseFactory
);
expect(resp.status).toEqual(200);
expect(resp.payload).toEqual({
jobId: JOB_ID,
nodeId: undefined,
snapshotId: SNAPSHOT_ID,
status: 'idle',
});
});
it('returns "in_progress" status if snapshot upgrade is in progress', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.getModelSnapshots as jest.Mock).mockResolvedValue({
body: {
count: 1,
model_snapshots: [
{
job_id: JOB_ID,
min_version: '6.4.0',
timestamp: 1575402237000,
description: 'State persisted due to job close at 2019-12-03T19:43:57+0000',
snapshot_id: SNAPSHOT_ID,
snapshot_doc_count: 1,
model_size_stats: {},
latest_record_time_stamp: 1576971072000,
latest_result_time_stamp: 1576965600000,
retain: false,
},
],
},
});
(routeHandlerContextMock.core.savedObjects.client.find as jest.Mock).mockResolvedValue({
total: 1,
saved_objects: [
{
attributes: {
nodeId: NODE_ID,
jobId: JOB_ID,
snapshotId: SNAPSHOT_ID,
},
},
],
});
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.tasks
.list as jest.Mock).mockResolvedValue({
body: {
nodes: {
[NODE_ID]: {
tasks: {
[`${NODE_ID}:12345`]: {
description: `job-snapshot-upgrade-${JOB_ID}-${SNAPSHOT_ID}`,
},
},
},
},
},
});
const resp = await routeDependencies.router.getHandler({
method: 'get',
pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}',
})(
routeHandlerContextMock,
createRequestMock({
params: {
snapshotId: SNAPSHOT_ID,
jobId: JOB_ID,
},
}),
kibanaResponseFactory
);
expect(resp.status).toEqual(200);
expect(resp.payload).toEqual({
jobId: JOB_ID,
nodeId: NODE_ID,
snapshotId: SNAPSHOT_ID,
status: 'in_progress',
});
});
it('returns "complete" status if snapshot upgrade has completed', async () => {
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml
.getModelSnapshots as jest.Mock).mockResolvedValue({
body: {
count: 1,
model_snapshots: [
{
job_id: JOB_ID,
min_version: '6.4.0',
timestamp: 1575402237000,
description: 'State persisted due to job close at 2019-12-03T19:43:57+0000',
snapshot_id: SNAPSHOT_ID,
snapshot_doc_count: 1,
model_size_stats: {},
latest_record_time_stamp: 1576971072000,
latest_result_time_stamp: 1576965600000,
retain: false,
},
],
},
});
(routeHandlerContextMock.core.savedObjects.client.find as jest.Mock).mockResolvedValue({
total: 1,
saved_objects: [
{
attributes: {
nodeId: NODE_ID,
jobId: JOB_ID,
snapshotId: SNAPSHOT_ID,
},
},
],
});
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.tasks
.list as jest.Mock).mockResolvedValue({
body: {
nodes: {
[NODE_ID]: {
tasks: {},
},
},
},
});
(routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.migration
.deprecations as jest.Mock).mockResolvedValue({
body: {
cluster_settings: [],
ml_settings: [],
node_settings: [],
index_settings: {},
},
});
(routeHandlerContextMock.core.savedObjects.client.delete as jest.Mock).mockResolvedValue({});
const resp = await routeDependencies.router.getHandler({
method: 'get',
pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}',
})(
routeHandlerContextMock,
createRequestMock({
params: {
snapshotId: SNAPSHOT_ID,
jobId: JOB_ID,
},
}),
kibanaResponseFactory
);
expect(resp.status).toEqual(200);
expect(resp.payload).toEqual({
jobId: JOB_ID,
nodeId: NODE_ID,
snapshotId: SNAPSHOT_ID,
status: 'complete',
});
});
});
});

View file

@ -0,0 +1,348 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ResponseError } from '@elastic/elasticsearch/lib/errors';
import { schema } from '@kbn/config-schema';
import { IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server';
import { API_BASE_PATH } from '../../common/constants';
import { MlOperation, ML_UPGRADE_OP_TYPE } from '../../common/types';
import { versionCheckHandlerWrapper } from '../lib/es_version_precheck';
import { handleEsError } from '../shared_imports';
import { RouteDependencies } from '../types';
const findMlOperation = async (
savedObjectsClient: SavedObjectsClientContract,
snapshotId: string
) => {
return savedObjectsClient.find<MlOperation>({
type: ML_UPGRADE_OP_TYPE,
search: `"${snapshotId}"`,
searchFields: ['snapshotId'],
});
};
const createMlOperation = async (
savedObjectsClient: SavedObjectsClientContract,
attributes: MlOperation
) => {
const foundSnapshots = await findMlOperation(savedObjectsClient, attributes.snapshotId);
if (foundSnapshots?.total > 0) {
throw new Error(`A ML operation is already in progress for snapshot: ${attributes.snapshotId}`);
}
return savedObjectsClient.create<MlOperation>(ML_UPGRADE_OP_TYPE, attributes);
};
const deleteMlOperation = (savedObjectsClient: SavedObjectsClientContract, id: string) => {
return savedObjectsClient.delete(ML_UPGRADE_OP_TYPE, id);
};
/*
* The tasks API can only tell us if the snapshot upgrade is in progress.
* We cannot rely on it to determine if a snapshot was upgraded successfully.
* If the task does not exist, it can mean one of two things:
* 1. The snapshot was upgraded successfully.
* 2. There was a failure upgrading the snapshot.
* In order to verify it was successful, we need to recheck the deprecation info API
* and verify the deprecation no longer exists. If it still exists, we assume there was a failure.
*/
const verifySnapshotUpgrade = async (
esClient: IScopedClusterClient,
snapshot: { snapshotId: string; jobId: string }
): Promise<{
isSuccessful: boolean;
error?: ResponseError;
}> => {
const { snapshotId, jobId } = snapshot;
try {
const { body: deprecations } = await esClient.asCurrentUser.migration.deprecations();
const mlSnapshotDeprecations = deprecations.ml_settings.filter((deprecation) => {
return /model snapshot/.test(deprecation.message);
});
// If there are no ML deprecations, we assume the deprecation was resolved successfully
if (typeof mlSnapshotDeprecations === 'undefined' || mlSnapshotDeprecations.length === 0) {
return {
isSuccessful: true,
};
}
const isSuccessful = Boolean(
mlSnapshotDeprecations.find((snapshotDeprecation) => {
const regex = /(?<=\[).*?(?=\])/g;
const matches = snapshotDeprecation.message.match(regex);
if (matches?.length === 2) {
// If there is no matching snapshot, we assume the deprecation was resolved successfully
return matches[0] === snapshotId && matches[1] === jobId ? false : true;
}
return false;
})
);
return {
isSuccessful,
};
} catch (e) {
return {
isSuccessful: false,
error: e,
};
}
};
export function registerMlSnapshotRoutes({ router }: RouteDependencies) {
// Upgrade ML model snapshot
router.post(
{
path: `${API_BASE_PATH}/ml_snapshots`,
validate: {
body: schema.object({
snapshotId: schema.string(),
jobId: schema.string(),
}),
},
},
versionCheckHandlerWrapper(
async (
{
core: {
savedObjects: { client: savedObjectsClient },
elasticsearch: { client: esClient },
},
},
request,
response
) => {
try {
const { snapshotId, jobId } = request.body;
const { body } = await esClient.asCurrentUser.ml.upgradeJobSnapshot({
job_id: jobId,
snapshot_id: snapshotId,
});
const snapshotInfo: MlOperation = {
nodeId: body.node,
snapshotId,
jobId,
};
// Store snapshot in saved object if upgrade not complete
if (body.completed !== true) {
await createMlOperation(savedObjectsClient, snapshotInfo);
}
return response.ok({
body: {
...snapshotInfo,
status: body.completed === true ? 'complete' : 'in_progress',
},
});
} catch (e) {
return handleEsError({ error: e, response });
}
}
)
);
// Get the status of the upgrade snapshot task
router.get(
{
path: `${API_BASE_PATH}/ml_snapshots/{jobId}/{snapshotId}`,
validate: {
params: schema.object({
snapshotId: schema.string(),
jobId: schema.string(),
}),
},
},
versionCheckHandlerWrapper(
async (
{
core: {
savedObjects: { client: savedObjectsClient },
elasticsearch: { client: esClient },
},
},
request,
response
) => {
try {
const { snapshotId, jobId } = request.params;
// Verify snapshot exists
await esClient.asCurrentUser.ml.getModelSnapshots({
job_id: jobId,
snapshot_id: snapshotId,
});
const foundSnapshots = await findMlOperation(savedObjectsClient, snapshotId);
// If snapshot is *not* found in SO, assume there has not been an upgrade operation started
if (typeof foundSnapshots === 'undefined' || foundSnapshots.total === 0) {
return response.ok({
body: {
snapshotId,
jobId,
nodeId: undefined,
status: 'idle',
},
});
}
const snapshotOp = foundSnapshots.saved_objects[0];
const { nodeId } = snapshotOp.attributes;
// Now that we have the node ID, check the upgrade snapshot task progress
const { body: taskResponse } = await esClient.asCurrentUser.tasks.list({
nodes: [nodeId],
actions: 'xpack/ml/job/snapshot/upgrade',
detailed: true, // necessary in order to filter if there are more than 1 snapshot upgrades in progress
});
const nodeTaskInfo = taskResponse?.nodes && taskResponse!.nodes[nodeId];
const snapshotInfo: MlOperation = {
...snapshotOp.attributes,
};
if (nodeTaskInfo) {
// Find the correct snapshot task ID based on the task description
const snapshotTaskId = Object.keys(nodeTaskInfo.tasks).find((task) => {
// The description is in the format of "job-snapshot-upgrade-<job_id>-<snapshot_id>"
const taskDescription = nodeTaskInfo.tasks[task].description;
const taskSnapshotAndJobIds = taskDescription!.replace('job-snapshot-upgrade-', '');
const taskSnapshotAndJobIdParts = taskSnapshotAndJobIds.split('-');
const taskSnapshotId =
taskSnapshotAndJobIdParts[taskSnapshotAndJobIdParts.length - 1];
const taskJobId = taskSnapshotAndJobIdParts.slice(0, 1).join('-');
return taskSnapshotId === snapshotId && taskJobId === jobId;
});
// If the snapshot task exists, assume the upgrade is in progress
if (snapshotTaskId && nodeTaskInfo.tasks[snapshotTaskId]) {
return response.ok({
body: {
...snapshotInfo,
status: 'in_progress',
},
});
} else {
// The task ID was not found; verify the deprecation was resolved
const {
isSuccessful: isSnapshotDeprecationResolved,
error: upgradeSnapshotError,
} = await verifySnapshotUpgrade(esClient, {
snapshotId,
jobId,
});
// Delete the SO; if it's complete, no need to store it anymore. If there's an error, this will give the user a chance to retry
await deleteMlOperation(savedObjectsClient, snapshotOp.id);
if (isSnapshotDeprecationResolved) {
return response.ok({
body: {
...snapshotInfo,
status: 'complete',
},
});
}
return response.customError({
statusCode: upgradeSnapshotError ? upgradeSnapshotError.statusCode : 500,
body: {
message:
upgradeSnapshotError?.body?.error?.reason ||
'There was an error upgrading your snapshot. Check the Elasticsearch logs for more details.',
},
});
}
} else {
// No tasks found; verify the deprecation was resolved
const {
isSuccessful: isSnapshotDeprecationResolved,
error: upgradeSnapshotError,
} = await verifySnapshotUpgrade(esClient, {
snapshotId,
jobId,
});
// Delete the SO; if it's complete, no need to store it anymore. If there's an error, this will give the user a chance to retry
await deleteMlOperation(savedObjectsClient, snapshotOp.id);
if (isSnapshotDeprecationResolved) {
return response.ok({
body: {
...snapshotInfo,
status: 'complete',
},
});
}
return response.customError({
statusCode: upgradeSnapshotError ? upgradeSnapshotError.statusCode : 500,
body: {
message:
upgradeSnapshotError?.body?.error?.reason ||
'There was an error upgrading your snapshot. Check the Elasticsearch logs for more details.',
},
});
}
} catch (e) {
return handleEsError({ error: e, response });
}
}
)
);
// Delete ML model snapshot
router.delete(
{
path: `${API_BASE_PATH}/ml_snapshots/{jobId}/{snapshotId}`,
validate: {
params: schema.object({
snapshotId: schema.string(),
jobId: schema.string(),
}),
},
},
versionCheckHandlerWrapper(
async (
{
core: {
elasticsearch: { client },
},
},
request,
response
) => {
try {
const { snapshotId, jobId } = request.params;
const {
body: deleteSnapshotResponse,
} = await client.asCurrentUser.ml.deleteModelSnapshot({
job_id: jobId,
snapshot_id: snapshotId,
});
return response.ok({
body: deleteSnapshotResponse,
});
} catch (e) {
return handleEsError({ error: e, response });
}
}
)
);
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RouteDependencies } from '../types';
import { registerClusterCheckupRoutes } from './cluster_checkup';
import { registerDeprecationLoggingRoutes } from './deprecation_logging';
import { registerReindexIndicesRoutes } from './reindex_indices';
import { registerTelemetryRoutes } from './telemetry';
import { registerUpdateSettingsRoute } from './update_index_settings';
import { registerMlSnapshotRoutes } from './ml_snapshots';
import { ReindexWorker } from '../lib/reindexing';
export function registerRoutes(dependencies: RouteDependencies, getWorker: () => ReindexWorker) {
registerClusterCheckupRoutes(dependencies);
registerDeprecationLoggingRoutes(dependencies);
registerReindexIndicesRoutes(dependencies, getWorker);
registerTelemetryRoutes(dependencies);
registerUpdateSettingsRoute(dependencies);
registerMlSnapshotRoutes(dependencies);
}

View file

@ -7,3 +7,4 @@
export { reindexOperationSavedObjectType } from './reindex_operation_saved_object_type';
export { telemetrySavedObjectType } from './telemetry_saved_object_type';
export { mlSavedObjectType } from './ml_upgrade_operation_saved_object_type';

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SavedObjectsType } from 'src/core/server';
import { ML_UPGRADE_OP_TYPE } from '../../common/types';
export const mlSavedObjectType: SavedObjectsType = {
name: ML_UPGRADE_OP_TYPE,
hidden: false,
namespaceType: 'agnostic',
mappings: {
properties: {
nodeId: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
snapshotId: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
jobId: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
status: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
},
},
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { handleEsError } from '../../../../src/plugins/es_ui_shared/server';

View file

@ -6,7 +6,6 @@
*/
import { IRouter, Logger, SavedObjectsServiceStart } from 'src/core/server';
import { CloudSetup } from '../../cloud/server';
import { CredentialStore } from './lib/reindexing/credential_store';
import { LicensingPluginSetup } from '../../licensing/server';
@ -16,5 +15,4 @@ export interface RouteDependencies {
log: Logger;
getSavedObjectsService: () => SavedObjectsServiceStart;
licensing: LicensingPluginSetup;
cloud?: CloudSetup;
}

View file

@ -20,8 +20,8 @@
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/management/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../cloud/tsconfig.json" },
{ "path": "../features/tsconfig.json" },
{ "path": "../licensing/tsconfig.json" },
{ "path": "../../../src/plugins/es_ui_shared/tsconfig.json" },
]
}