diff --git a/src/legacy/core_plugins/elasticsearch/index.d.ts b/src/legacy/core_plugins/elasticsearch/index.d.ts index a744a4cc22e53..c9ce96f2ef5bd 100644 --- a/src/legacy/core_plugins/elasticsearch/index.d.ts +++ b/src/legacy/core_plugins/elasticsearch/index.d.ts @@ -149,7 +149,7 @@ import { export class Cluster { public callWithRequest: CallClusterWithRequest; - public callWithInternalUser: CallClusterWithInternalUser; + public callWithInternalUser: CallCluster; public constructor(config: ClusterConfig); } @@ -376,11 +376,156 @@ export interface CallClusterWithRequest { ): Promise; } -export type CallClusterWithInternalUser = ( - endpoint: string, - clientParams: GenericParams, - options?: CallClusterOptions -) => Promise; +export interface CallCluster { + /* tslint:disable */ + (endpoint: 'bulk', params: BulkIndexDocumentsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'clearScroll', params: ClearScrollParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'count', params: CountParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'create', params: CreateDocumentParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'delete', params: DeleteDocumentParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'deleteByQuery', params: DeleteDocumentByQueryParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'deleteScript', params: DeleteScriptParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'deleteTemplate', params: DeleteTemplateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'exists', params: ExistsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'explain', params: ExplainParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'fieldStats', params: FieldStatsParams, options?: CallClusterOptions): ReturnType; + // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. + (endpoint: 'get', params: GetParams, options?: CallClusterOptions): Promise>; + (endpoint: 'getScript', params: GetScriptParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'getSource', params: GetSourceParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'getTemplate', params: GetTemplateParams, options?: CallClusterOptions): ReturnType; + // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. + (endpoint: 'index', params: IndexDocumentParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'info', params: InfoParams, options?: CallClusterOptions): ReturnType; + // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. + (endpoint: 'mget', params: MGetParams, options?: CallClusterOptions): Promise>; + (endpoint: 'msearch', params: MSearchParams, options?: CallClusterOptions): Promise>; + (endpoint: 'msearchTemplate', params: MSearchTemplateParams, options?: CallClusterOptions): Promise>; + (endpoint: 'mtermvectors', params: MTermVectorsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'ping', params: PingParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'putScript', params: PutScriptParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'putTemplate', params: PutTemplateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'reindex', params: ReindexParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'reindexRethrottle', params: ReindexRethrottleParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'renderSearchTemplate', params: RenderSearchTemplateParams, options?: CallClusterOptions): ReturnType; + // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. + (endpoint: 'scroll', params: ScrollParams, options?: CallClusterOptions): Promise>; + (endpoint: 'search', params: SearchParams, options?: CallClusterOptions): Promise>; + (endpoint: 'searchShards', params: SearchShardsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'searchTemplate', params: SearchTemplateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'suggest', params: SuggestParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'termvectors', params: TermvectorsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'update', params: UpdateDocumentParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'updateByQuery', params: UpdateDocumentByQueryParams, options?: CallClusterOptions): ReturnType; + + // cat namespace + (endpoint: 'cat.aliases', params: CatAliasesParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.allocation', params: CatAllocationParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.count', params: CatAllocationParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.fielddata', params: CatFielddataParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.health', params: CatHealthParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.help', params: CatHelpParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.indices', params: CatIndicesParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.master', params: CatCommonParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.nodeattrs', params: CatCommonParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.nodes', params: CatCommonParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.pendingTasks', params: CatCommonParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.plugins', params: CatCommonParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.recovery', params: CatRecoveryParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.repositories', params: CatCommonParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.segments', params: CatSegmentsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.shards', params: CatShardsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.snapshots', params: CatSnapshotsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.tasks', params: CatTasksParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cat.threadPool', params: CatThreadPoolParams, options?: CallClusterOptions): ReturnType; + + // cluster namespace + (endpoint: 'cluster.allocationExplain', params: ClusterAllocationExplainParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cluster.getSettings', params: ClusterGetSettingsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cluster.health', params: ClusterHealthParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cluster.pendingTasks', params: ClusterPendingTasksParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cluster.putSettings', params: ClusterPutSettingsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cluster.reroute', params: ClusterRerouteParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cluster.state', params: ClusterStateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'cluster.stats', params: ClusterStatsParams, options?: CallClusterOptions): ReturnType; + + // indices namespace + (endpoint: 'indices.analyze', params: IndicesAnalyzeParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.clearCache', params: IndicesClearCacheParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.close', params: IndicesCloseParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.create', params: IndicesCreateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.delete', params: IndicesDeleteParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.deleteAlias', params: IndicesDeleteAliasParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.deleteTemplate', params: IndicesDeleteTemplateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.exists', params: IndicesExistsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.existsAlias', params: IndicesExistsAliasParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.existsTemplate', params: IndicesExistsTemplateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.existsType', params: IndicesExistsTypeParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.flush', params: IndicesFlushParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.flushSynced', params: IndicesFlushSyncedParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.forcemerge', params: IndicesForcemergeParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.get', params: IndicesGetParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.getAlias', params: IndicesGetAliasParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.getFieldMapping', params: IndicesGetFieldMappingParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.getMapping', params: IndicesGetMappingParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.getSettings', params: IndicesGetSettingsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.getTemplate', params: IndicesGetTemplateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.getUpgrade', params: IndicesGetUpgradeParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.open', params: IndicesOpenParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.putAlias', params: IndicesPutAliasParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.putMapping', params: IndicesPutMappingParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.putSettings', params: IndicesPutSettingsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.putTemplate', params: IndicesPutTemplateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.recovery', params: IndicesRecoveryParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.refresh', params: IndicesRefreshParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.rollover', params: IndicesRolloverParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.segments', params: IndicesSegmentsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.shardStores', params: IndicesShardStoresParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.shrink', params: IndicesShrinkParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.stats', params: IndicesStatsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.updateAliases', params: IndicesUpdateAliasesParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.upgrade', params: IndicesUpgradeParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'indices.validateQuery', params: IndicesValidateQueryParams, options?: CallClusterOptions): ReturnType; + + // ingest namepsace + (endpoint: 'ingest.deletePipeline', params: IngestDeletePipelineParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'ingest.getPipeline', params: IngestGetPipelineParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'ingest.putPipeline', params: IngestPutPipelineParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'ingest.simulate', params: IngestSimulateParams, options?: CallClusterOptions): ReturnType; + + // nodes namespace + (endpoint: 'nodes.hotThreads', params: NodesHotThreadsParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'nodes.info', params: NodesInfoParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'nodes.stats', params: NodesStatsParams, options?: CallClusterOptions): ReturnType; + + // snapshot namespace + (endpoint: 'snapshot.create', params: SnapshotCreateParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.createRepository', params: SnapshotCreateRepositoryParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.delete', params: SnapshotDeleteParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.deleteRepository', params: SnapshotDeleteRepositoryParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.get', params: SnapshotGetParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.getRepository', params: SnapshotGetRepositoryParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.restore', params: SnapshotRestoreParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.status', params: SnapshotStatusParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'snapshot.verifyRepository', params: SnapshotVerifyRepositoryParams, options?: CallClusterOptions): ReturnType; + + // tasks namespace + (endpoint: 'tasks.cancel', params: TasksCancelParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'tasks.get', params: TasksGetParams, options?: CallClusterOptions): ReturnType; + (endpoint: 'tasks.list', params: TasksListParams, options?: CallClusterOptions): ReturnType; + /* tslint:enable */ + + // other APIs accessed via transport.request + (endpoint: 'transport.request', clientParams: AssistantAPIClientParams, options?: {}): Promise< + AssistanceAPIResponse + >; + (endpoint: 'transport.request', clientParams: DeprecationAPIClientParams, options?: {}): Promise< + DeprecationAPIResponse + >; + + // Catch-all definition + (endpoint: string, clientParams: any, options?: CallClusterOptions): Promise; +} export interface ElasticsearchPlugin { ElasticsearchClientLogging: ElasticsearchClientLogging; diff --git a/src/server/kbn_server.d.ts b/src/server/kbn_server.d.ts index 5e3ecf691a123..9ec8a6349045d 100644 --- a/src/server/kbn_server.d.ts +++ b/src/server/kbn_server.d.ts @@ -18,9 +18,10 @@ */ import { Server } from 'hapi'; + import { CallClusterWithRequest, ElasticsearchPlugin } from '../legacy/core_plugins/elasticsearch'; import { IndexPatternsServiceFactory } from './index_patterns'; -import { SavedObjectsService } from './saved_objects'; +import { SavedObjectsClient, SavedObjectsService } from './saved_objects'; export interface KibanaConfig { get(key: string): T; @@ -31,6 +32,7 @@ declare module 'hapi' { interface PluginProperties { elasticsearch: ElasticsearchPlugin; kibana: any; + spaces: any; // add new plugin types here } @@ -41,11 +43,9 @@ declare module 'hapi' { } interface Request { - getBasePath: () => string; - } - - interface Request { - getUiSettingsService: () => any; + getSavedObjectsClient(): SavedObjectsClient; + getBasePath(): string; + getUiSettingsService(): any; } } diff --git a/src/server/saved_objects/service/create_saved_objects_service.d.ts b/src/server/saved_objects/service/create_saved_objects_service.d.ts index 6fa27ca4d7d81..086ac02dad75b 100644 --- a/src/server/saved_objects/service/create_saved_objects_service.d.ts +++ b/src/server/saved_objects/service/create_saved_objects_service.d.ts @@ -17,19 +17,16 @@ * under the License. */ -import { SavedObjectsRepository, ScopedSavedObjectsClientProvider } from './lib'; +import { ScopedSavedObjectsClientProvider } from './lib'; import { SavedObjectsClient } from './saved_objects_client'; export interface SavedObjectsService { // ATTENTION: these types are incomplete - addScopedSavedObjectsClientWrapperFactory: ScopedSavedObjectsClientProvider< Request >['addClientWrapperFactory']; - getSavedObjectsRepository: ( - callCluster: (endpoint: string, clientParams: any, options: any) => Promise - ) => SavedObjectsRepository; getScopedSavedObjectsClient: ScopedSavedObjectsClientProvider['getClient']; SavedObjectsClient: typeof SavedObjectsClient; types: string[]; + getSavedObjectsRepository(...rest: any[]): any; } diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index 2f28081e96606..28d7a52db2b59 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -313,7 +313,7 @@ export class SavedObjectsRepository { } if (fields && !Array.isArray(fields)) { - throw new TypeError('options.searchFields must be an array'); + throw new TypeError('options.fields must be an array'); } const esOptions = { diff --git a/src/server/saved_objects/service/saved_objects_client.d.ts b/src/server/saved_objects/service/saved_objects_client.d.ts index 3df4249a48a01..a6e10aa1b85b1 100644 --- a/src/server/saved_objects/service/saved_objects_client.d.ts +++ b/src/server/saved_objects/service/saved_objects_client.d.ts @@ -28,30 +28,32 @@ export interface CreateOptions extends BaseOptions { override?: boolean; } -export interface BulkCreateObject { +export interface BulkCreateObject { id?: string; type: string; - attributes: SavedObjectAttributes; + attributes: T; extraDocumentProperties?: string[]; } -export interface BulkCreateResponse { - savedObjects: SavedObject[]; +export interface BulkCreateResponse { + savedObjects: Array>; } export interface FindOptions extends BaseOptions { + type?: string | string[]; page?: number; perPage?: number; sortField?: string; sortOrder?: string; fields?: string[]; - type?: string | string[]; + search?: string; + searchFields?: string[]; } -export interface FindResponse { - saved_objects: SavedObject[]; +export interface FindResponse { + saved_objects: Array>; total: number; - perPage: number; + per_page: number; page: number; } @@ -65,15 +67,15 @@ export interface BulkGetObject { } export type BulkGetObjects = BulkGetObject[]; -export interface BulkGetResponse { - savedObjects: SavedObject[]; +export interface BulkGetResponse { + savedObjects: Array>; } export interface SavedObjectAttributes { [key: string]: SavedObjectAttributes | string | number | boolean | null; } -export interface SavedObject { +export interface SavedObject { id: string; type: string; version?: number; @@ -81,30 +83,41 @@ export interface SavedObject { error?: { message: string; }; - attributes: SavedObjectAttributes; + attributes: T; } export declare class SavedObjectsClient { public static errors: typeof errors; public errors: typeof errors; - public create: ( + + constructor(repository: SavedObjectsRepository); + + public create( type: string, - attributes: SavedObjectAttributes, + attributes: T, options?: CreateOptions - ) => Promise; - public bulkCreate: ( - objects: BulkCreateObject[], + ): Promise>; + public bulkCreate( + objects: Array>, options?: CreateOptions - ) => Promise; - public delete: (type: string, id: string, options?: BaseOptions) => Promise<{}>; - public find: (options: FindOptions) => Promise; - public bulkGet: (objects: BulkGetObjects, options?: BaseOptions) => Promise; - public get: (type: string, id: string, options?: BaseOptions) => Promise; - public update: ( + ): Promise>; + public delete(type: string, id: string, options?: BaseOptions): Promise<{}>; + public find( + options: FindOptions + ): Promise>; + public bulkGet( + objects: BulkGetObjects, + options?: BaseOptions + ): Promise>; + public get( + type: string, + id: string, + options?: BaseOptions + ): Promise>; + public update( type: string, id: string, - attributes: SavedObjectAttributes, + attributes: Partial, options?: UpdateOptions - ) => Promise; - constructor(repository: SavedObjectsRepository); + ): Promise>; } diff --git a/x-pack/package.json b/x-pack/package.json index e375853a51777..79ead4b403719 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -132,6 +132,7 @@ "@scant/router": "^0.1.0", "@slack/client": "^4.8.0", "@turf/boolean-contains": "6.0.1", + "@types/json-stable-stringify": "^1.0.32", "angular-resource": "1.4.9", "angular-sanitize": "1.6.5", "angular-ui-ace": "0.2.3", @@ -187,6 +188,7 @@ "io-ts": "^1.4.2", "joi": "^13.5.2", "jquery": "^3.3.1", + "json-stable-stringify": "^1.0.1", "jsonwebtoken": "^8.3.0", "lodash": "npm:@elastic/lodash@3.10.1-kibana1", "lodash.keyby": "^4.6.0", diff --git a/x-pack/plugins/__mocks__/ui/chrome.js b/x-pack/plugins/__mocks__/ui/chrome.js index 38f31a42a51d0..e56124bede6e1 100644 --- a/x-pack/plugins/__mocks__/ui/chrome.js +++ b/x-pack/plugins/__mocks__/ui/chrome.js @@ -36,8 +36,13 @@ function getInjected(key) { } } +function getXsrfToken() { + return 'kbn'; +} + export default { getInjected, addBasePath, - getUiSettingsClient + getUiSettingsClient, + getXsrfToken }; diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts index ccb93b6d326fb..2f69dbe34db1a 100644 --- a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts @@ -84,7 +84,11 @@ export class SpacesSavedObjectsClient implements SavedObjectsClient { * @property {string} [options.namespace] * @returns {promise} - { id, type, version, attributes } */ - public async create(type: string, attributes = {}, options: CreateOptions = {}) { + public async create( + type: string, + attributes: T = {} as T, + options: CreateOptions = {} + ) { throwErrorIfTypeIsSpace(type); throwErrorIfNamespaceSpecified(options); @@ -215,10 +219,10 @@ export class SpacesSavedObjectsClient implements SavedObjectsClient { * @property {string} [options.namespace] * @returns {promise} */ - public async update( + public async update( type: string, id: string, - attributes: SavedObjectAttributes, + attributes: Partial, options: UpdateOptions = {} ) { throwErrorIfTypeIsSpace(type); diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts index e61d2e2c19359..7c928298995a3 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts @@ -5,11 +5,12 @@ */ // @ts-ignore -import { PluginProperties, Server } from 'hapi'; +import { Server } from 'hapi'; +import { Legacy } from 'kibana'; import { SpacesClient } from '../../../lib/spaces_client'; import { createSpaces } from './create_spaces'; -interface KibanaServer extends Server { +interface KibanaServer extends Legacy.Server { savedObjects: any; } @@ -46,13 +47,6 @@ const baseConfig: TestConfig = { 'xpack.spaces.maxSpaces': 1000, }; -// Merge / extend default interfaces for hapi. This is all faked out below. -declare module 'hapi' { - interface PluginProperties { - spaces: any; - } -} - export function createTestHandler(initApiFn: (server: any, preCheckLicenseImpl: any) => void) { const teardowns: TeardownFn[] = []; diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts new file mode 100644 index 0000000000000..4dc6917ecd7ab --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -0,0 +1,52 @@ +/* + * 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 { + SavedObject, + SavedObjectAttributes, +} from 'src/server/saved_objects/service/saved_objects_client'; + +export enum ReindexStep { + // Enum values are spaced out by 10 to give us room to insert steps in between. + created = 0, + indexConsumersStopped = 10, + readonly = 20, + newIndexCreated = 30, + reindexStarted = 40, + reindexCompleted = 50, + aliasCreated = 60, + indexConsumersStarted = 70, +} + +export enum ReindexStatus { + inProgress, + completed, + failed, + paused, +} + +export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation'; +export interface ReindexOperation extends SavedObjectAttributes { + indexName: string; + newIndexName: string; + status: ReindexStatus; + lastCompletedStep: ReindexStep; + locked: string | null; + reindexTaskId: string | null; + reindexTaskPercComplete: number | null; + errorMessage: string | null; + mlReindexCount: number | null; +} + +export type ReindexSavedObject = SavedObject; + +export enum ReindexWarning { + // 6.0 -> 7.0 warnings, now unused + allField = 0, + booleanFields = 1, + + // 7.0 -> 8.0 warnings +} diff --git a/x-pack/plugins/upgrade_assistant/common/version.ts b/x-pack/plugins/upgrade_assistant/common/version.ts index 1e3746257fcd5..adab2f71f3567 100644 --- a/x-pack/plugins/upgrade_assistant/common/version.ts +++ b/x-pack/plugins/upgrade_assistant/common/version.ts @@ -12,3 +12,4 @@ const matches = currentVersionNum.match(/^([1-9]+)\.([0-9]+)\.([0-9]+)$/)!; export const CURRENT_MAJOR_VERSION = matches[1]; export const NEXT_MAJOR_VERSION = (parseInt(CURRENT_MAJOR_VERSION, 10) + 1).toString(); +export const PREV_MAJOR_VERSION = (parseInt(CURRENT_MAJOR_VERSION, 10) - 1).toString(); diff --git a/x-pack/plugins/upgrade_assistant/index.ts b/x-pack/plugins/upgrade_assistant/index.ts index f0926f026bf01..e8f03767dc49d 100644 --- a/x-pack/plugins/upgrade_assistant/index.ts +++ b/x-pack/plugins/upgrade_assistant/index.ts @@ -15,6 +15,12 @@ export function upgradeAssistant(kibana: any) { uiExports: { managementSections: ['plugins/upgrade_assistant'], styleSheetPaths: resolve(__dirname, 'public/index.scss'), + mappings: require('./mappings.json'), + savedObjectSchemas: { + 'upgrade-assistant-reindex-operation': { + isNamespaceAgnostic: true, + }, + }, }, publicDir: resolve(__dirname, 'public'), diff --git a/x-pack/plugins/upgrade_assistant/mappings.json b/x-pack/plugins/upgrade_assistant/mappings.json new file mode 100644 index 0000000000000..bf1b1957a4e30 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/mappings.json @@ -0,0 +1,13 @@ +{ + "upgrade-assistant-reindex-operation": { + "dynamic": true, + "properties": { + "indexName": { + "type": "keyword" + }, + "status": { + "type": "integer" + } + } + } +} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs.test.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs.test.tsx index d059778908313..e606fd13f1e32 100644 --- a/x-pack/plugins/upgrade_assistant/public/components/tabs.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs.test.tsx @@ -9,6 +9,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; jest.mock('axios', () => ({ get: jest.fn(), + create: jest.fn(), })); import { UpgradeAssistantTabs } from './tabs'; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/_index.scss b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/_index.scss index 4ebfb7dae4291..e370aeac0dfa2 100644 --- a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/_index.scss +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/_index.scss @@ -1,4 +1,5 @@ @import './cell'; +@import './reindex/index'; .upgDeprecations { // Pull the container through the padding of EuiPageContent diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/cell.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/cell.tsx index 700e74fa822a3..29a0076d00d0e 100644 --- a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/cell.tsx +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/cell.tsx @@ -7,7 +7,6 @@ import React, { ReactNode, StatelessComponent } from 'react'; import { - EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -17,16 +16,14 @@ import { EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ReindexButton } from './reindex'; interface DeprecationCellProps { items?: Array<{ title?: string; body: string }>; + reindexIndexName?: string; docUrl?: string; headline?: string; healthColor?: string; - actions?: Array<{ - label: string; - url: string; - }>; children?: ReactNode; } @@ -36,7 +33,7 @@ interface DeprecationCellProps { export const DeprecationCell: StatelessComponent = ({ headline, healthColor, - actions, + reindexIndexName, docUrl, items = [], children, @@ -78,14 +75,11 @@ export const DeprecationCell: StatelessComponent = ({ ))} - {actions && - actions.map(button => ( - - - {button.label} - - - ))} + {reindexIndexName && ( + + + + )} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.test.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.test.tsx index 7c862856af927..8c211704c7aff 100644 --- a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.test.tsx @@ -10,12 +10,11 @@ import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { IndexDeprecationTableProps, IndexDeprecationTableUI } from './index_table'; describe('IndexDeprecationTable', () => { - const actions = [{ label: 'Do it', url: 'http://justdoit.com' }]; const defaultProps = { indices: [ - { index: 'index1', details: 'Index 1 deets', actions }, - { index: 'index2', details: 'Index 2 deets', actions }, - { index: 'index3', details: 'Index 3 deets', actions }, + { index: 'index1', details: 'Index 1 deets', reindex: true }, + { index: 'index2', details: 'Index 2 deets', reindex: true }, + { index: 'index3', details: 'Index 3 deets', reindex: true }, ], } as IndexDeprecationTableProps; @@ -49,34 +48,19 @@ describe('IndexDeprecationTable', () => { items={ Array [ Object { - "actions": Array [ - Object { - "label": "Do it", - "url": "http://justdoit.com", - }, - ], "details": "Index 1 deets", "index": "index1", + "reindex": true, }, Object { - "actions": Array [ - Object { - "label": "Do it", - "url": "http://justdoit.com", - }, - ], "details": "Index 2 deets", "index": "index2", + "reindex": true, }, Object { - "actions": Array [ - Object { - "label": "Do it", - "url": "http://justdoit.com", - }, - ], "details": "Index 3 deets", "index": "index3", + "reindex": true, }, ] } diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.tsx index b94354efac4a7..5deed2523e215 100644 --- a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.tsx +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/index_table.tsx @@ -7,18 +7,16 @@ import { sortBy } from 'lodash'; import React from 'react'; -import { EuiBasicTable, EuiButton } from '@elastic/eui'; +import { EuiBasicTable } from '@elastic/eui'; import { injectI18n } from '@kbn/i18n/react'; +import { ReindexButton } from './reindex'; const PAGE_SIZES = [10, 25, 50, 100, 250, 500, 1000]; export interface IndexDeprecationDetails { index: string; + reindex: boolean; details?: string; - actions?: Array<{ - label: string; - url: string; - }>; } export interface IndexDeprecationTableProps extends ReactIntl.InjectedIntlProps { @@ -134,26 +132,21 @@ export class IndexDeprecationTableUI extends React.Component< } private get actionsColumn() { - // NOTE: this naive implementation assumes all indices in the table have - // the same actions (can still have different URLs). This should work for known usecases. + // NOTE: this naive implementation assumes all indices in the table are + // should show the reindex button. This should work for known usecases. const { indices } = this.props; - if (!indices.find(i => i.actions !== undefined)) { + if (!indices.find(i => i.reindex)) { return null; } - const actions = indices[0].actions!; - return { - actions: actions.map((action, idx) => ({ - render(index: IndexDeprecationDetails) { - const { url, label } = index.actions![idx]; - return ( - - {label} - - ); + actions: [ + { + render(indexDep: IndexDeprecationDetails) { + return ; + }, }, - })), + ], }; } } diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.test.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.test.tsx index 01eea28dd4048..fb69634fbdcc0 100644 --- a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.test.tsx @@ -71,14 +71,14 @@ describe('DeprecationList', () => { indices={ Array [ Object { - "actions": undefined, "details": undefined, "index": "0", + "reindex": false, }, Object { - "actions": undefined, "details": undefined, "index": "1", + "reindex": false, }, ] } diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.tsx index a4071a5649d81..0783802b6c0c6 100644 --- a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.tsx +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/list.tsx @@ -10,10 +10,13 @@ import { DeprecationInfo } from 'src/legacy/core_plugins/elasticsearch'; import { EnrichedDeprecationInfo } from '../../../../../server/lib/es_migration_apis'; import { GroupByOption } from '../../../types'; +import { CURRENT_MAJOR_VERSION } from 'x-pack/plugins/upgrade_assistant/common/version'; import { COLOR_MAP, LEVEL_MAP } from '../constants'; import { DeprecationCell } from './cell'; import { IndexDeprecationDetails, IndexDeprecationTable } from './index_table'; +const OLD_INDEX_MESSAGE = `Index created before ${CURRENT_MAJOR_VERSION}.0`; + const sortByLevelDesc = (a: DeprecationInfo, b: DeprecationInfo) => { return -1 * (LEVEL_MAP[a.level] - LEVEL_MAP[b.level]); }; @@ -34,7 +37,7 @@ const MessageDeprecation: StatelessComponent<{ deprecation: EnrichedDeprecationI @@ -83,17 +86,16 @@ export const DeprecationList: StatelessComponent<{ // If we're grouping by message and the first deprecation has an index field, show an index // group deprecation. Otherwise, show each message. if (currentGroupBy === GroupByOption.message && deprecations[0].index !== undefined) { - // If we're grouping by index we assume that every deprecation message is the same - // issue and that each deprecation will have an index associated with it. + // 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, - actions: dep.actions, + reindex: dep.message === OLD_INDEX_MESSAGE, })); return ; } else if (currentGroupBy === GroupByOption.index) { - // If we're grouping by index show all info for each message return (
{deprecations.sort(sortByLevelDesc).map(dep => ( diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/_button.scss b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/_button.scss new file mode 100644 index 0000000000000..f12149f9e88cb --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/_button.scss @@ -0,0 +1,5 @@ +.upgReindexButton__spinner { + position: relative; + top: $euiSizeXS / 2; + margin-right: $euiSizeXS; +} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/_index.scss b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/_index.scss new file mode 100644 index 0000000000000..2d52575cffbbb --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/_index.scss @@ -0,0 +1,2 @@ +@import './button'; +@import './flyout/index'; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/button.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/button.tsx new file mode 100644 index 0000000000000..1df8dc690a823 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/button.tsx @@ -0,0 +1,143 @@ +/* + * 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 React, { Fragment, ReactNode } from 'react'; +import { Subscription } from 'rxjs'; + +import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import { ReindexStatus } from '../../../../../../common/types'; +import { LoadingState } from '../../../../types'; +import { ReindexFlyout } from './flyout'; +import { ReindexPollingService, ReindexState } from './polling_service'; + +interface ReindexButtonProps { + indexName: string; +} + +interface ReindexButtonState { + flyoutVisible: boolean; + reindexState: ReindexState; +} + +/** + * Displays a button that will display a flyout when clicked with the reindexing status for + * the given `indexName`. + */ +export class ReindexButton extends React.Component { + private service: ReindexPollingService; + private subscription?: Subscription; + + constructor(props: ReindexButtonProps) { + super(props); + + this.service = this.newService(); + this.state = { + flyoutVisible: false, + reindexState: this.service.status$.value, + }; + } + + public async componentDidMount() { + this.subscribeToUpdates(); + } + + public async componentWillUnmount() { + this.unsubscribeToUpdates(); + } + + public componentDidUpdate(prevProps: ReindexButtonProps) { + if (prevProps.indexName !== this.props.indexName) { + this.unsubscribeToUpdates(); + this.service = this.newService(); + this.subscribeToUpdates(); + } + } + + public render() { + const { indexName } = this.props; + const { flyoutVisible, reindexState } = this.state; + + const buttonProps: any = { size: 's', onClick: this.showFlyout }; + let buttonContent: ReactNode = 'Reindex'; + + if (reindexState.loadingState === LoadingState.Loading) { + buttonProps.disabled = true; + buttonContent = 'Loading…'; + } else { + switch (reindexState.status) { + case ReindexStatus.inProgress: + buttonContent = ( + + Reindexing… + + ); + break; + case ReindexStatus.completed: + buttonProps.color = 'secondary'; + buttonProps.iconSide = 'left'; + buttonProps.iconType = 'check'; + buttonContent = 'Done'; + break; + case ReindexStatus.failed: + buttonProps.color = 'danger'; + buttonProps.iconSide = 'left'; + buttonProps.iconType = 'cross'; + buttonContent = 'Failed'; + break; + case ReindexStatus.paused: + buttonProps.color = 'warning'; + buttonProps.iconSide = 'left'; + buttonProps.iconType = 'pause'; + buttonContent = 'Paused'; + } + } + + return ( + + {buttonContent} + + {flyoutVisible && ( + + )} + + ); + } + + private newService() { + return new ReindexPollingService(this.props.indexName); + } + + private subscribeToUpdates() { + this.service.updateStatus(); + this.subscription = this.service!.status$.subscribe(reindexState => + this.setState({ reindexState }) + ); + } + + private unsubscribeToUpdates() { + if (this.subscription) { + this.subscription.unsubscribe(); + delete this.subscription; + } + + if (this.service) { + this.service.stopPolling(); + } + } + + private showFlyout = () => { + this.setState({ flyoutVisible: true }); + }; + + private closeFlyout = () => { + this.setState({ flyoutVisible: false }); + }; +} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap new file mode 100644 index 0000000000000..bc84757f8ba58 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChecklistFlyout renders 1`] = ` + + + +

+ If you can’t stop document updates or need to reindex into a new cluster, consider using a different upgrade strategy. +

+

+ Reindexing will continue in the background, but if Kibana shuts down or restarts you will need to return to this page to resume reindexing. +

+
+ + +

+ Reindexing process +

+
+ +
+ + + + + Close + + + + + Reindexing… + + + + +
+`; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/warning_step.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/warning_step.test.tsx.snap new file mode 100644 index 0000000000000..bd06a5e6b82d3 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/warning_step.test.tsx.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WarningsFlyoutStep renders 1`] = ` + + + +

+ Back up your index, then proceed with the reindex by accepting each breaking change. +

+
+ + + + + _all + + field will be removed + + } + onChange={[Function]} + /> +

+ The + + _all + + meta field is no longer supported in 7.0. Reindexing removes the + + _all + + field in the new index. Ensure that no application code or scripts reply on this field. +
+ + Documentation + +

+
+ + + + Boolean data in + + _source + + might change + + } + onChange={[Function]} + /> +

+ If a documents contain a boolean field that is neither + + true + + or + + + false + + (for example, + + "yes" + + , + + + "on" + + , + + 1 + + ), reindexing converts these fields to + + + true + + or + + false + + . Ensure that no application code or scripts rely on boolean fields in the deprecated format. +
+ + Documentation + +

+
+
+ + + + + Cancel + + + + + Continue with reindex + + + + +
+`; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_index.scss b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_index.scss new file mode 100644 index 0000000000000..f695ae175feca --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_index.scss @@ -0,0 +1,2 @@ +@import './step_progress'; +@import './warnings_step'; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_step_progress.scss b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_step_progress.scss new file mode 100644 index 0000000000000..d2522993f3001 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_step_progress.scss @@ -0,0 +1,45 @@ +@import '@elastic/eui/src/components/call_out/variables'; +@import '@elastic/eui/src/components/call_out/mixins'; + +.upgStepProgress__step { + display: flex; + align-items: center; + margin-top: $euiSize; + margin-bottom: $euiSizeS; + line-height: $euiSize; +} + +.upgStepProgress__status { + @include size($euiSize); + margin-right: $euiSizeM; +} + +$stepStatusToCallOutColor: ( + failed: 'danger', + complete: 'success', + paused: 'warning', +); + +.upgStepProgress__status--circle { + text-align: center; + border-radius: $euiSizeM; + line-height: $euiSize - 2px; + + @each $status, $callOutColor in $stepStatusToCallOutColor { + &-#{$status} { + color: euiCallOutColor($callOutColor, 'foreground'); + background-color: euiCallOutColor($callOutColor, 'background'); + } + } +} + +.upgStepProgress__title { + &--currentStep { + font-weight: $euiFontWeightBold; + } +} + +.upgStepProgress__content { + display: block; + margin-left: $euiSize + $euiSizeM; +} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_warnings_step.scss b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_warnings_step.scss new file mode 100644 index 0000000000000..4279c0110ea75 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/_warnings_step.scss @@ -0,0 +1,4 @@ +.upgWarningsStep__warningDescription { + margin-left: $euiSizeL; + margin-top: $euiSizeXS; +} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.test.tsx new file mode 100644 index 0000000000000..2e0547bb8d0b5 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; + +import { ReindexStatus, ReindexStep, ReindexWarning } from '../../../../../../../common/types'; +import { LoadingState } from '../../../../../types'; +import { ChecklistFlyoutStep } from './checklist_step'; + +describe('ChecklistFlyout', () => { + const defaultProps = { + indexName: 'myIndex', + closeFlyout: jest.fn(), + confirmInputValue: 'CONFIRM', + onConfirmInputChange: jest.fn(), + startReindex: jest.fn(), + reindexState: { + loadingState: LoadingState.Success, + lastCompletedStep: ReindexStep.readonly, + status: ReindexStatus.inProgress, + reindexTaskPercComplete: null, + errorMessage: null, + reindexWarnings: [ReindexWarning.allField], + }, + }; + + it('renders', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('disables button while reindexing', () => { + const wrapper = shallow(); + expect(wrapper.find('EuiButton').props().disabled).toBe(true); + }); + + it('calls startReindex when button is clicked', () => { + const props = { + ...defaultProps, + reindexState: { + ...defaultProps.reindexState, + lastCompletedStep: undefined, + status: undefined, + }, + }; + const wrapper = shallow(); + + wrapper.find('EuiButton').simulate('click'); + expect(props.startReindex).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.tsx new file mode 100644 index 0000000000000..31902344b376e --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.tsx @@ -0,0 +1,108 @@ +/* + * 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 React, { Fragment } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { ReindexStatus } from '../../../../../../../common/types'; +import { LoadingState } from '../../../../../types'; +import { ReindexState } from '../polling_service'; +import { ReindexProgress } from './progress'; + +const buttonLabel = (status?: ReindexStatus) => { + switch (status) { + case ReindexStatus.failed: + return 'Try again'; + case ReindexStatus.inProgress: + return 'Reindexing…'; + case ReindexStatus.completed: + return 'Done!'; + case ReindexStatus.paused: + return 'Resume'; + default: + return 'Run reindex'; + } +}; + +/** + * Displays a flyout that shows the current reindexing status for a given index. + */ +export const ChecklistFlyoutStep: React.StatelessComponent<{ + closeFlyout: () => void; + reindexState: ReindexState; + startReindex: () => void; +}> = ({ closeFlyout, reindexState, startReindex }) => { + const { + loadingState, + status, + reindexTaskPercComplete, + lastCompletedStep, + errorMessage, + } = reindexState; + const loading = loadingState === LoadingState.Loading || status === ReindexStatus.inProgress; + + return ( + + + +

+ If you can’t stop document updates or need to reindex into a new cluster, consider using + a different upgrade strategy. +

+

+ Reindexing will continue in the background, but if Kibana shuts down or restarts you + will need to return to this page to resume reindexing. +

+
+ + +

Reindexing process

+
+ +
+ + + + + Close + + + + + {buttonLabel(status)} + + + + +
+ ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/container.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/container.tsx new file mode 100644 index 0000000000000..01c032ffb9340 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/container.tsx @@ -0,0 +1,93 @@ +/* + * 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 React from 'react'; + +import { EuiFlyout, EuiFlyoutHeader, EuiPortal, EuiTitle } from '@elastic/eui'; + +import { ReindexState } from '../polling_service'; +import { ChecklistFlyoutStep } from './checklist_step'; +import { WarningsFlyoutStep } from './warnings_step'; + +enum ReindexFlyoutStep { + reindexWarnings, + checklist, +} + +interface ReindexFlyoutProps { + indexName: string; + closeFlyout: () => void; + reindexState: ReindexState; + startReindex: () => void; +} + +interface ReindexFlyoutState { + currentFlyoutStep: ReindexFlyoutStep; +} + +/** + * Wrapper for the contents of the flyout that manages which step of the flyout to show. + */ +export class ReindexFlyout extends React.Component { + constructor(props: ReindexFlyoutProps) { + super(props); + const { status, reindexWarnings } = props.reindexState; + + this.state = { + // If there are any warnings and we haven't started reindexing, show the warnings step first. + currentFlyoutStep: + reindexWarnings && reindexWarnings.length > 0 && status === undefined + ? ReindexFlyoutStep.reindexWarnings + : ReindexFlyoutStep.checklist, + }; + } + + public render() { + const { closeFlyout, indexName, reindexState, startReindex } = this.props; + const { currentFlyoutStep } = this.state; + + let flyoutContents: React.ReactNode; + switch (currentFlyoutStep) { + case ReindexFlyoutStep.reindexWarnings: + flyoutContents = ( + + ); + break; + case ReindexFlyoutStep.checklist: + flyoutContents = ( + + ); + break; + default: + throw new Error(`Invalid flyout step: ${currentFlyoutStep}`); + } + + return ( + + + + +

Reindex {indexName}

+
+
+ {flyoutContents} +
+
+ ); + } + + public advanceNextStep = () => { + this.setState({ currentFlyoutStep: ReindexFlyoutStep.checklist }); + }; +} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/index.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/index.tsx new file mode 100644 index 0000000000000..c93544ad30e26 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/index.tsx @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { ReindexFlyout } from './container'; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/progress.test.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/progress.test.tsx new file mode 100644 index 0000000000000..2c671395bfccc --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/progress.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; + +import { ReindexStatus, ReindexStep } from '../../../../../../../common/types'; +import { ReindexProgress } from './progress'; + +describe('ReindexProgress', () => { + it('renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchInlineSnapshot(` + +`); + }); + + it('displays errors in the step that failed', () => { + const wrapper = shallow( + + ); + + const aliasStep = wrapper.props().steps[3]; + expect(aliasStep.children.props.errorMessage).toEqual( + `This is an error that happened on alias switch` + ); + }); + + it('shows reindexing document progress bar', () => { + const wrapper = shallow( + + ); + + const reindexStep = wrapper.props().steps[2]; + expect(reindexStep.children.type.name).toEqual('EuiProgress'); + expect(reindexStep.children.props.value).toEqual(0.25); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/progress.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/progress.tsx new file mode 100644 index 0000000000000..9e4fcfff0398e --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/progress.tsx @@ -0,0 +1,123 @@ +/* + * 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 React from 'react'; + +import { EuiCallOut, EuiProgress, EuiText } from '@elastic/eui'; + +import { ReindexStatus, ReindexStep } from '../../../../../../../common/types'; +import { StepProgress, StepProgressStep } from './step_progress'; + +const ErrorCallout: React.StatelessComponent<{ errorMessage: string | null }> = ({ + errorMessage, +}) => ( + + +

{errorMessage}

+
+
+); + +const PausedCallout = () => ( + +); + +const orderedSteps = Object.values(ReindexStep).sort() as number[]; + +/** + * Displays a list of steps in the reindex operation, the current status, a progress bar, + * and any error messages that are encountered. + */ +export const ReindexProgress: React.StatelessComponent<{ + lastCompletedStep?: ReindexStep; + reindexStatus?: ReindexStatus; + reindexTaskPercComplete: number | null; + errorMessage: string | null; +}> = ({ lastCompletedStep = -1, reindexStatus, reindexTaskPercComplete, errorMessage }) => { + const stepDetails = (thisStep: ReindexStep): Pick => { + const previousStep = orderedSteps[orderedSteps.indexOf(thisStep) - 1]; + + if (reindexStatus === ReindexStatus.failed && lastCompletedStep === previousStep) { + return { + status: 'failed', + children: , + }; + } else if (reindexStatus === ReindexStatus.paused && lastCompletedStep === previousStep) { + return { + status: 'paused', + children: , + }; + } else if (reindexStatus === undefined || lastCompletedStep < previousStep) { + return { + status: 'incomplete', + }; + } else if (lastCompletedStep === previousStep) { + return { + status: 'inProgress', + }; + } else { + return { + status: 'complete', + }; + } + }; + + // The reindexing step is special because it combines the starting and complete statuses into a single UI + // with a progress bar. + const reindexingDocsStep = { title: 'Reindexing documents' } as StepProgressStep; + if ( + reindexStatus === ReindexStatus.failed && + (lastCompletedStep === ReindexStep.newIndexCreated || + lastCompletedStep === ReindexStep.reindexStarted) + ) { + reindexingDocsStep.status = 'failed'; + reindexingDocsStep.children = ; + } else if ( + reindexStatus === ReindexStatus.paused && + (lastCompletedStep === ReindexStep.newIndexCreated || + lastCompletedStep === ReindexStep.reindexStarted) + ) { + reindexingDocsStep.status = 'paused'; + reindexingDocsStep.children = ; + } else if (reindexStatus === undefined || lastCompletedStep < ReindexStep.newIndexCreated) { + reindexingDocsStep.status = 'incomplete'; + } else { + reindexingDocsStep.status = + lastCompletedStep === ReindexStep.newIndexCreated || + lastCompletedStep === ReindexStep.reindexStarted + ? 'inProgress' + : 'complete'; + + reindexingDocsStep.children = reindexTaskPercComplete ? ( + + ) : ( + + ); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/step_progress.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/step_progress.tsx new file mode 100644 index 0000000000000..c04529b83d4af --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/step_progress.tsx @@ -0,0 +1,83 @@ +/* + * 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 classNames from 'classnames'; +import React, { Fragment, ReactNode } from 'react'; + +import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; + +type STATUS = 'incomplete' | 'inProgress' | 'complete' | 'failed' | 'paused'; + +const StepStatus: React.StatelessComponent<{ status: STATUS; idx: number }> = ({ status, idx }) => { + if (status === 'incomplete') { + return {idx + 1}.; + } else if (status === 'inProgress') { + return ; + } else if (status === 'complete') { + return ( + + + + ); + } else if (status === 'paused') { + return ( + + + + ); + } else if (status === 'failed') { + return ( + + + + ); + } + + throw new Error(`Unsupported status: ${status}`); +}; + +const Step: React.StatelessComponent = ({ + title, + status, + children, + idx, +}) => { + const titleClassName = classNames('upgStepProgress__title', { + 'upgStepProgress__title--currentStep': + status === 'inProgress' || status === 'paused' || status === 'failed', + }); + + return ( + +
+ +

{title}

+
+ {children &&
{children}
} +
+ ); +}; + +export interface StepProgressStep { + title: string; + status: STATUS; + children?: ReactNode; +} + +/** + * A generic component that displays a series of automated steps and the system's progress. + */ +export const StepProgress: React.StatelessComponent<{ + steps: StepProgressStep[]; +}> = ({ steps }) => { + return ( +
+ {steps.map((step, idx) => ( + + ))} +
+ ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx new file mode 100644 index 0000000000000..230878a2fd31b --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; + +import { ReindexWarning } from '../../../../../../../common/types'; +import { idForWarning, WarningsFlyoutStep } from './warnings_step'; + +describe('WarningsFlyoutStep', () => { + const defaultProps = { + advanceNextStep: jest.fn(), + warnings: [ReindexWarning.allField, ReindexWarning.booleanFields], + closeFlyout: jest.fn(), + }; + + it('renders', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('does not allow proceeding until all are checked', () => { + const wrapper = mount(); + const button = wrapper.find('EuiButton'); + + button.simulate('click'); + expect(defaultProps.advanceNextStep).not.toHaveBeenCalled(); + + wrapper.find(`input#${idForWarning(ReindexWarning.allField)}`).simulate('change'); + button.simulate('click'); + expect(defaultProps.advanceNextStep).not.toHaveBeenCalled(); + + wrapper.find(`input#${idForWarning(ReindexWarning.booleanFields)}`).simulate('change'); + button.simulate('click'); + expect(defaultProps.advanceNextStep).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx new file mode 100644 index 0000000000000..8a4c2eb7bf516 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx @@ -0,0 +1,168 @@ +/* + * 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 React, { Fragment } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCheckbox, + EuiCode, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { ReindexWarning } from '../../../../../../../common/types'; + +export const idForWarning = (warning: ReindexWarning) => `reindexWarning-${warning}`; + +interface WarningsConfirmationFlyoutProps { + closeFlyout: () => void; + warnings: ReindexWarning[]; + advanceNextStep: () => void; +} + +interface WarningsConfirmationFlyoutState { + checkedIds: { [id: string]: boolean }; +} + +/** + * Displays warning text about destructive changes required to reindex this index. The user + * must acknowledge each change before being allowed to proceed. + */ +export class WarningsFlyoutStep extends React.Component< + WarningsConfirmationFlyoutProps, + WarningsConfirmationFlyoutState +> { + constructor(props: WarningsConfirmationFlyoutProps) { + super(props); + + this.state = { + checkedIds: props.warnings.reduce( + (checkedIds, warning) => { + checkedIds[idForWarning(warning)] = false; + return checkedIds; + }, + {} as { [id: string]: boolean } + ), + }; + } + + public render() { + const { warnings, closeFlyout, advanceNextStep } = this.props; + const { checkedIds } = this.state; + + // Do not allow to proceed until all checkboxes are checked. + const blockAdvance = Object.values(checkedIds).filter(v => v).length < warnings.length; + + return ( + + + +

+ Back up your index, then proceed with the reindex by accepting each breaking change. +

+
+ + + + {warnings.includes(ReindexWarning.allField) && ( + + + _all field will be removed + + } + checked={checkedIds[idForWarning(ReindexWarning.allField)]} + onChange={this.onChange} + /> +

+ The _all meta field is no longer supported in 7.0. Reindexing + removes the _all field in the new index. Ensure that no + application code or scripts reply on this field. +
+ + Documentation + +

+
+ )} + + + + {warnings.includes(ReindexWarning.booleanFields) && ( + + + Boolean data in _source might change + + } + checked={checkedIds[idForWarning(ReindexWarning.booleanFields)]} + onChange={this.onChange} + /> +

+ If a documents contain a boolean field that is neither true or{' '} + false (for example, "yes",{' '} + "on", 1), reindexing converts these fields to{' '} + true or false. Ensure that no application code + or scripts rely on boolean fields in the deprecated format. +
+ + Documentation + +

+
+ )} +
+ + + + + Cancel + + + + + Continue with reindex + + + + +
+ ); + } + + private onChange = (e: React.ChangeEvent) => { + const optionId = e.target.id; + const nextCheckedIds = { + ...this.state.checkedIds, + ...{ + [optionId]: !this.state.checkedIds[optionId], + }, + }; + + this.setState({ checkedIds: nextCheckedIds }); + }; +} diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/index.tsx b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/index.tsx new file mode 100644 index 0000000000000..5fcebb75e1784 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/index.tsx @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { ReindexButton } from './button'; diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/polling_service.test.ts b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/polling_service.test.ts new file mode 100644 index 0000000000000..f0a03dee99553 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/polling_service.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { ReindexStatus, ReindexStep } from '../../../../../../common/types'; + +const mockClient = { + post: jest.fn().mockResolvedValue({ + lastCompletedStep: ReindexStep.created, + status: ReindexStatus.inProgress, + }), + get: jest.fn().mockResolvedValue({ + status: 200, + data: { + warnings: [], + reindexOp: null, + }, + }), +}; +jest.mock('axios', () => ({ + create: jest.fn().mockReturnValue(mockClient), +})); + +import { ReindexPollingService } from './polling_service'; + +describe('ReindexPollingService', () => { + beforeEach(() => { + mockClient.post.mockReset(); + mockClient.get.mockReset(); + }); + + it('does not poll when reindexOp is null', async () => { + mockClient.get.mockResolvedValueOnce({ + status: 200, + data: { + warnings: [], + reindexOp: null, + }, + }); + + const service = new ReindexPollingService('myIndex'); + service.updateStatus(); + await new Promise(resolve => setTimeout(resolve, 1200)); // wait for poll interval + + expect(mockClient.get).toHaveBeenCalledTimes(1); + service.stopPolling(); + }); + + it('does not poll when first check is a 200 and status is failed', async () => { + mockClient.get.mockResolvedValue({ + status: 200, + data: { + warnings: [], + reindexOp: { + lastCompletedStep: ReindexStep.created, + status: ReindexStatus.failed, + errorMessage: `Oh no!`, + }, + }, + }); + + const service = new ReindexPollingService('myIndex'); + service.updateStatus(); + await new Promise(resolve => setTimeout(resolve, 1200)); // wait for poll interval + + expect(mockClient.get).toHaveBeenCalledTimes(1); + expect(service.status$.value.errorMessage).toEqual(`Oh no!`); + service.stopPolling(); + }); + + it('begins to poll when first check is a 200 and status is inProgress', async () => { + mockClient.get.mockResolvedValue({ + status: 200, + data: { + warnings: [], + reindexOp: { + lastCompletedStep: ReindexStep.created, + status: ReindexStatus.inProgress, + }, + }, + }); + + const service = new ReindexPollingService('myIndex'); + service.updateStatus(); + await new Promise(resolve => setTimeout(resolve, 1200)); // wait for poll interval + + expect(mockClient.get).toHaveBeenCalledTimes(2); + service.stopPolling(); + }); + + describe('startReindex', () => { + it('posts to endpoint', async () => { + const service = new ReindexPollingService('myIndex'); + await service.startReindex(); + + expect(mockClient.post).toHaveBeenCalledWith('/api/upgrade_assistant/reindex/myIndex'); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/polling_service.ts b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/polling_service.ts new file mode 100644 index 0000000000000..76a7dbaaa1179 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/components/tabs/checkup/deprecations/reindex/polling_service.ts @@ -0,0 +1,135 @@ +/* + * 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 axios from 'axios'; +import chrome from 'ui/chrome'; + +import { BehaviorSubject } from 'rxjs'; +import { + ReindexOperation, + ReindexStatus, + ReindexStep, + ReindexWarning, +} from '../../../../../../common/types'; +import { LoadingState } from '../../../../types'; + +const POLL_INTERVAL = 1000; +const XSRF = chrome.getXsrfToken(); + +export const APIClient = axios.create({ + headers: { + Accept: 'application/json', + credentials: 'same-origin', + 'Content-Type': 'application/json', + 'kbn-version': XSRF, + 'kbn-xsrf': XSRF, + }, +}); + +export interface ReindexState { + loadingState: LoadingState; + lastCompletedStep?: ReindexStep; + status?: ReindexStatus; + reindexTaskPercComplete: number | null; + errorMessage: string | null; + reindexWarnings?: ReindexWarning[]; +} + +interface StatusResponse { + warnings?: ReindexWarning[]; + reindexOp?: ReindexOperation; +} + +/** + * Service used by the frontend to start reindexing and get updates on the state of a reindex + * operation. Exposes an Observable that can be used to subscribe to state updates. + */ +export class ReindexPollingService { + public status$: BehaviorSubject; + private pollTimeout?: NodeJS.Timeout; + + constructor(private indexName: string) { + this.status$ = new BehaviorSubject({ + loadingState: LoadingState.Loading, + errorMessage: null, + reindexTaskPercComplete: null, + }); + } + + public updateStatus = async () => { + // Prevent two loops from being started. + this.stopPolling(); + + try { + const { data } = await APIClient.get( + chrome.addBasePath(`/api/upgrade_assistant/reindex/${this.indexName}`) + ); + this.updateWithResponse(data); + + // Only keep polling if it exists and is in progress. + if (data.reindexOp && data.reindexOp.status === ReindexStatus.inProgress) { + this.pollTimeout = setTimeout(this.updateStatus, POLL_INTERVAL); + } + } catch (e) { + this.status$.next({ + ...this.status$.value, + status: ReindexStatus.failed, + }); + } + }; + + public stopPolling = () => { + if (this.pollTimeout) { + clearTimeout(this.pollTimeout); + } + }; + + public startReindex = async () => { + try { + // Optimistically assume it will start, reset other state. + const currentValue = this.status$.value; + this.status$.next({ + ...currentValue, + // Only reset last completed step if we aren't currently paused + lastCompletedStep: + currentValue.status === ReindexStatus.paused ? currentValue.lastCompletedStep : undefined, + status: ReindexStatus.inProgress, + reindexTaskPercComplete: null, + errorMessage: null, + }); + const { data } = await APIClient.post( + chrome.addBasePath(`/api/upgrade_assistant/reindex/${this.indexName}`) + ); + + this.updateWithResponse({ reindexOp: data }); + this.updateStatus(); + } catch (e) { + this.status$.next({ ...this.status$.value, status: ReindexStatus.failed }); + } + }; + + private updateWithResponse = ({ reindexOp, warnings }: StatusResponse) => { + // Next value should always include the entire state, not just what changes. + // We make a shallow copy as a starting new state. + const nextValue = { + ...this.status$.value, + // If we're getting any updates, set to success. + loadingState: LoadingState.Success, + }; + + if (warnings) { + nextValue.reindexWarnings = warnings; + } + + if (reindexOp) { + nextValue.lastCompletedStep = reindexOp.lastCompletedStep; + nextValue.status = reindexOp.status; + nextValue.reindexTaskPercComplete = reindexOp.reindexTaskPercComplete; + nextValue.errorMessage = reindexOp.errorMessage; + } + + this.status$.next(nextValue); + }; +} diff --git a/x-pack/plugins/upgrade_assistant/server/index.ts b/x-pack/plugins/upgrade_assistant/server/index.ts index 759fdd288d615..d9362bfeb2e30 100644 --- a/x-pack/plugins/upgrade_assistant/server/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/index.ts @@ -5,10 +5,23 @@ */ import { Legacy } from 'kibana'; +import { credentialStoreFactory } from './lib/reindexing/credential_store'; import { registerClusterCheckupRoutes } from './routes/cluster_checkup'; import { registerDeprecationLoggingRoutes } from './routes/deprecation_logging'; +import { registerReindexIndicesRoutes, registerReindexWorker } from './routes/reindex_indices'; export function initServer(server: Legacy.Server) { registerClusterCheckupRoutes(server); registerDeprecationLoggingRoutes(server); + + // The ReindexWorker uses a map of request headers that contain the authentication credentials + // for a given reindex. We cannot currently store these in an the .kibana index b/c we do not + // want to expose these credentials to any unauthenticated users. We also want to avoid any need + // to add a user for a special index just for upgrading. This in-memory cache allows us to + // process jobs without the browser staying on the page, but will require that jobs go into + // a paused state if no Kibana nodes have the required credentials. + const credentialStore = credentialStoreFactory(); + + const worker = registerReindexWorker(server, credentialStore); + registerReindexIndicesRoutes(server, worker, credentialStore); } diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts index c97f85b3c8a61..5fd06feed9419 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts @@ -14,7 +14,7 @@ describe('getUpgradeAssistantStatus', () => { let deprecationsResponse: DeprecationAPIResponse; const callWithRequest = jest.fn().mockImplementation(async (req, api, { path }) => { - if (path === '/_xpack/migration/deprecations') { + if (path === '/_migration/deprecations') { return deprecationsResponse; } else { throw new Error(`Unexpected API call: ${path}`); @@ -25,10 +25,10 @@ describe('getUpgradeAssistantStatus', () => { deprecationsResponse = _.cloneDeep(fakeDeprecations); }); - it('calls /_xpack/migration/deprecations', async () => { + it('calls /_migration/deprecations', async () => { await getUpgradeAssistantStatus(callWithRequest, {} as any, '/'); expect(callWithRequest).toHaveBeenCalledWith({}, 'transport.request', { - path: '/_xpack/migration/deprecations', + path: '/_migration/deprecations', method: 'GET', }); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts index 0e23314b3648b..0682e6acbce9e 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts @@ -12,10 +12,6 @@ import { DeprecationAPIResponse, DeprecationInfo } from 'src/legacy/core_plugins export interface EnrichedDeprecationInfo extends DeprecationInfo { index?: string; node?: string; - actions?: Array<{ - label: string; - url: string; - }>; } export interface UpgradeAssistantStatus { @@ -31,7 +27,7 @@ export async function getUpgradeAssistantStatus( basePath: string ): Promise { const deprecations = (await callWithRequest(req, 'transport.request', { - path: '/_xpack/migration/deprecations', + path: '/_migration/deprecations', method: 'GET', })) as DeprecationAPIResponse; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.test.ts new file mode 100644 index 0000000000000..ce892df0de946 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { ReindexSavedObject } from '../../../common/types'; +import { Credential, credentialStoreFactory } from './credential_store'; + +describe('credentialStore', () => { + it('retrieves the same credentials for the same state', () => { + const creds = { key: '1' } as Credential; + const reindexOp = { + id: 'asdf', + attributes: { indexName: 'test', lastCompletedStep: 1, locked: null }, + } as ReindexSavedObject; + + const credStore = credentialStoreFactory(); + credStore.set(reindexOp, creds); + expect(credStore.get(reindexOp)).toEqual(creds); + }); + + it('does retrieve credentials if the state is changed', () => { + const creds = { key: '1' } as Credential; + const reindexOp = { + id: 'asdf', + attributes: { indexName: 'test', lastCompletedStep: 1, locked: null }, + } as ReindexSavedObject; + + const credStore = credentialStoreFactory(); + credStore.set(reindexOp, creds); + + reindexOp.attributes.lastCompletedStep = 0; + expect(credStore.get(reindexOp)).not.toBeDefined(); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.ts new file mode 100644 index 0000000000000..32f5ec9977b72 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/credential_store.ts @@ -0,0 +1,51 @@ +/* + * 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 { createHash } from 'crypto'; +import { Request } from 'hapi'; +import stringify from 'json-stable-stringify'; + +import { ReindexSavedObject } from '../../../common/types'; + +export type Credential = Request['headers']; + +/** + * An in-memory cache for user credentials to be used for reindexing operations. When looking up + * credentials, the reindex operation must be in the same state it was in when the credentials + * were stored. This prevents any tampering of the .kibana index by an unpriviledged user from + * affecting the reindex process. + */ +export interface CredentialStore { + get(reindexOp: ReindexSavedObject): Credential | undefined; + set(reindexOp: ReindexSavedObject, credential: Credential): void; + clear(): void; +} + +export const credentialStoreFactory = (): CredentialStore => { + const credMap = new Map(); + + // Generates a stable hash for the reindex operation's current state. + const getHash = (reindexOp: ReindexSavedObject) => + createHash('sha256') + .update(stringify({ id: reindexOp.id, ...reindexOp.attributes })) + .digest('base64'); + + return { + get(reindexOp: ReindexSavedObject) { + return credMap.get(getHash(reindexOp)); + }, + + set(reindexOp: ReindexSavedObject, credential: Credential) { + credMap.set(getHash(reindexOp), credential); + }, + + clear() { + for (const k of credMap.keys()) { + credMap.delete(k); + } + }, + }; +}; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index.ts new file mode 100644 index 0000000000000..166fba2ea857f --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { reindexServiceFactory } from './reindex_service'; +export { ReindexWorker } from './worker'; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts new file mode 100644 index 0000000000000..ff992185c2729 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { getReindexWarnings, transformFlatSettings } from './index_settings'; + +describe('transformFlatSettings', () => { + it('does not blow up for empty mappings', () => { + expect( + transformFlatSettings({ + settings: {}, + mappings: {}, + }) + ).toEqual({ + settings: {}, + mappings: {}, + }); + }); + + it('removes settings that cannot be set on a new index', () => { + expect( + transformFlatSettings({ + settings: { + // Settings that should get preserved + 'index.number_of_replicas': '1', + 'index.number_of_shards': '5', + // Blacklisted settings + 'index.uuid': 'i66b9149a-00ee-42d9-8ca1-85ae927924bf', + 'index.blocks.write': 'true', + 'index.creation_date': '1547052614626', + 'index.legacy': '6', + 'index.mapping.single_type': 'true', + 'index.provided_name': 'test1', + 'index.routing.allocation.initial_recovery._id': '1', + 'index.version.created': '123123', + 'index.version.upgraded': '123123', + }, + mappings: {}, + }) + ).toEqual({ + settings: { + 'index.number_of_replicas': '1', + 'index.number_of_shards': '5', + }, + mappings: {}, + }); + }); +}); + +describe('getReindexWarnings', () => { + it('does not blow up for empty mappings', () => { + expect( + getReindexWarnings({ + settings: {}, + mappings: {}, + }) + ).toEqual([]); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts new file mode 100644 index 0000000000000..91e5e5f1cff7d --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts @@ -0,0 +1,55 @@ +/* + * 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 { flow, omit } from 'lodash'; +import { ReindexWarning } from '../../../common/types'; +import { FlatSettings } from './types'; + +/** + * Validates, and updates deprecated settings and mappings to be applied to the + * new updated index. + */ +export const transformFlatSettings = (flatSettings: FlatSettings) => { + const settings = transformSettings(flatSettings.settings); + const mappings = transformMappings(flatSettings.mappings); + + return { settings, mappings }; +}; + +/** + * Returns an array of warnings that should be displayed to user before reindexing begins. + * @param flatSettings + */ +export const getReindexWarnings = (flatSettings: FlatSettings): ReindexWarning[] => { + const warnings = [ + // No warnings yet for 7.0 -> 8.0 + ] as Array<[ReindexWarning, boolean]>; + + return warnings.filter(([_, applies]) => applies).map(([warning, _]) => warning); +}; + +const removeUnsettableSettings = (settings: FlatSettings['settings']) => + omit(settings, [ + 'index.uuid', + 'index.blocks.write', + 'index.creation_date', + 'index.legacy', + 'index.mapping.single_type', + 'index.provided_name', + 'index.routing.allocation.initial_recovery._id', + 'index.version.created', + 'index.version.upgraded', + ]); + +// Use `flow` to pipe the settings through each function. +const transformSettings = flow(removeUnsettableSettings); + +const updateFixableMappings = (mappings: FlatSettings['mappings']) => { + // TODO: change type to _doc + return mappings; +}; + +const transformMappings = flow(updateFixableMappings); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts new file mode 100644 index 0000000000000..d766dcc338574 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -0,0 +1,317 @@ +/* + * 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 Boom from 'boom'; +import moment from 'moment'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { + REINDEX_OP_TYPE, + ReindexSavedObject, + ReindexStatus, + ReindexStep, +} from 'x-pack/plugins/upgrade_assistant/common/types'; +import { + CURRENT_MAJOR_VERSION, + PREV_MAJOR_VERSION, +} from 'x-pack/plugins/upgrade_assistant/common/version'; +import { + LOCK_WINDOW, + ML_LOCK_DOC_ID, + ReindexActions, + reindexActionsFactory, +} from './reindex_actions'; + +describe('ReindexActions', () => { + let client: jest.Mocked; + let callCluster: jest.Mock; + let actions: ReindexActions; + + const unimplemented = (name: string) => () => + Promise.reject(`Mock function ${name} was not implemented!`); + + beforeEach(() => { + client = { + errors: null, + create: jest.fn(unimplemented('create')), + bulkCreate: jest.fn(unimplemented('bulkCreate')), + delete: jest.fn(unimplemented('delete')), + find: jest.fn(unimplemented('find')), + bulkGet: jest.fn(unimplemented('bulkGet')), + get: jest.fn(unimplemented('get')), + // Fake update implementation that simply resolves to whatever the update says. + update: jest.fn((type: string, id: string, attributes: object) => + Promise.resolve({ id, attributes } as ReindexSavedObject) + ) as any, + }; + callCluster = jest.fn(); + actions = reindexActionsFactory(client, callCluster); + }); + + describe('createReindexOp', () => { + beforeEach(() => client.create.mockResolvedValue()); + + it(`appends -reindexed-v${CURRENT_MAJOR_VERSION} to new name`, async () => { + await actions.createReindexOp('myIndex'); + expect(client.create).toHaveBeenCalledWith(REINDEX_OP_TYPE, { + indexName: 'myIndex', + newIndexName: `myIndex-reindexed-v${CURRENT_MAJOR_VERSION}`, + status: ReindexStatus.inProgress, + lastCompletedStep: ReindexStep.created, + locked: null, + reindexTaskId: null, + reindexTaskPercComplete: null, + errorMessage: null, + mlReindexCount: null, + }); + }); + + it(`replaces -reindexed-v${PREV_MAJOR_VERSION} with -reindexed-v${CURRENT_MAJOR_VERSION}`, async () => { + await actions.createReindexOp(`myIndex-reindexed-v${PREV_MAJOR_VERSION}`); + expect(client.create).toHaveBeenCalledWith(REINDEX_OP_TYPE, { + indexName: `myIndex-reindexed-v${PREV_MAJOR_VERSION}`, + newIndexName: `myIndex-reindexed-v${CURRENT_MAJOR_VERSION}`, + status: ReindexStatus.inProgress, + lastCompletedStep: ReindexStep.created, + locked: null, + reindexTaskId: null, + reindexTaskPercComplete: null, + errorMessage: null, + mlReindexCount: null, + }); + }); + }); + + describe('updateReindexOp', () => { + it('calls update with the combined attributes', async () => { + await actions.updateReindexOp( + { + type: REINDEX_OP_TYPE, + id: '9', + attributes: { indexName: 'hi', locked: moment().format() }, + version: 1, + } as ReindexSavedObject, + { newIndexName: 'test' } + ); + expect(client.update).toHaveBeenCalled(); + const args = client.update.mock.calls[0]; + expect(args[0]).toEqual(REINDEX_OP_TYPE); + expect(args[1]).toEqual('9'); + expect(args[2].indexName).toEqual('hi'); + expect(args[2].newIndexName).toEqual('test'); + expect(args[3]).toEqual({ version: 1 }); + }); + + it('throws if the reindexOp is not locked', async () => { + await expect( + actions.updateReindexOp( + { + type: REINDEX_OP_TYPE, + id: '10', + attributes: { indexName: 'hi', locked: null }, + version: 1, + } as ReindexSavedObject, + { newIndexName: 'test' } + ) + ).rejects.toThrow(); + expect(client.update).not.toHaveBeenCalled(); + }); + }); + + describe('runWhileLocked', () => { + it('locks and unlocks if object is unlocked', async () => { + const reindexOp = { id: '1', attributes: { locked: null } } as ReindexSavedObject; + await actions.runWhileLocked(reindexOp, op => Promise.resolve(op)); + + expect(client.update).toHaveBeenCalledTimes(2); + + // Locking update call + const id1 = client.update.mock.calls[0][1]; + const attr1 = client.update.mock.calls[0][2]; + expect(id1).toEqual('1'); + expect(attr1.locked).not.toBeNull(); + + // Unlocking update call + const id2 = client.update.mock.calls[1][1]; + const attr2 = client.update.mock.calls[1][2]; + expect(id2).toEqual('1'); + expect(attr2.locked).toBeNull(); + }); + + it("locks and unlocks if object's lock is expired", async () => { + const reindexOp = { + id: '1', + attributes: { + // Set locked timestamp to timeout + 10 seconds ago + locked: moment() + .subtract(LOCK_WINDOW) + .subtract(moment.duration(10, 'seconds')) + .format(), + }, + } as ReindexSavedObject; + await actions.runWhileLocked(reindexOp, op => Promise.resolve(op)); + + expect(client.update).toHaveBeenCalledTimes(2); + + // Locking update call + const id1 = client.update.mock.calls[0][1]; + const attr1 = client.update.mock.calls[0][2]; + expect(id1).toEqual('1'); + expect(attr1.locked).not.toBeNull(); + + // Unlocking update call + const id2 = client.update.mock.calls[1][1]; + const attr2 = client.update.mock.calls[1][2]; + expect(id2).toEqual('1'); + expect(attr2.locked).toBeNull(); + }); + + it('still locks and unlocks if func throws', async () => { + const reindexOp = { id: '1', attributes: { locked: null } } as ReindexSavedObject; + + await expect( + actions.runWhileLocked(reindexOp, op => Promise.reject(new Error('IT FAILED!'))) + ).rejects.toThrow('IT FAILED!'); + + expect(client.update).toHaveBeenCalledTimes(2); + + // Locking update call + const id1 = client.update.mock.calls[0][1]; + const attr1 = client.update.mock.calls[0][2]; + expect(id1).toEqual('1'); + expect(attr1.locked).not.toBeNull(); + + // Unlocking update call + const id2 = client.update.mock.calls[1][1]; + const attr2 = client.update.mock.calls[1][2]; + expect(id2).toEqual('1'); + expect(attr2.locked).toBeNull(); + }); + + it('throws if lock is not exprired', async () => { + const reindexOp = { + id: '1', + attributes: { locked: moment().format() }, + } as ReindexSavedObject; + await expect(actions.runWhileLocked(reindexOp, op => Promise.resolve(op))).rejects.toThrow(); + }); + }); + + describe('findAllByStatus', () => { + it('returns saved_objects', async () => { + client.find.mockResolvedValue({ saved_objects: ['results!'] }); + await expect(actions.findAllByStatus(ReindexStatus.inProgress)).resolves.toEqual([ + 'results!', + ]); + expect(client.find).toHaveBeenCalledWith({ + type: REINDEX_OP_TYPE, + search: '0', + searchFields: ['status'], + }); + }); + + it('handles paging', async () => { + client.find + .mockResolvedValueOnce({ + total: 20, + page: 0, + per_page: 10, + saved_objects: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }) + .mockResolvedValueOnce({ + total: 20, + page: 1, + per_page: 10, + saved_objects: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + }); + + // Really prettier?? + await expect(actions.findAllByStatus(ReindexStatus.completed)).resolves.toEqual([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + ]); + }); + }); + + describe('getFlatSettings', () => { + it('returns flat settings', async () => { + callCluster.mockResolvedValueOnce({ + myIndex: { + settings: { 'index.mySetting': '1' }, + mappings: {}, + }, + }); + await expect(actions.getFlatSettings('myIndex')).resolves.toEqual({ + settings: { 'index.mySetting': '1' }, + mappings: {}, + }); + }); + + it('returns null if index does not exist', async () => { + callCluster.mockResolvedValueOnce({}); + await expect(actions.getFlatSettings('myIndex')).resolves.toBeNull(); + }); + }); + + describe('runWhileMlLocked', () => { + it('creates the ML doc if it does not exist and executes callback', async () => { + expect.assertions(3); + client.get.mockRejectedValueOnce(Boom.notFound()); // mock no ML doc exists yet + client.create.mockImplementationOnce((type: any, attributes: any, { id }: any) => + Promise.resolve({ + type, + id, + attributes, + }) + ); + + let flip = false; + await actions.runWhileMlLocked(async mlDoc => { + expect(mlDoc.id).toEqual(ML_LOCK_DOC_ID); + expect(mlDoc.attributes.mlReindexCount).toEqual(0); + flip = true; + return mlDoc; + }); + expect(flip).toEqual(true); + }); + + it('fails after 10 attempts to lock', async () => { + jest.setTimeout(20000); // increase the timeout + client.get.mockResolvedValue({ + type: REINDEX_OP_TYPE, + id: ML_LOCK_DOC_ID, + attributes: { mlReindexCount: 0 }, + }); + + client.update.mockRejectedValue(new Error('NO LOCKING!')); + + await expect(actions.runWhileMlLocked(async m => m)).rejects.toThrow( + 'Could not acquire lock for ML jobs' + ); + expect(client.update).toHaveBeenCalledTimes(10); + + // Restore default timeout. + jest.setTimeout(5000); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts new file mode 100644 index 0000000000000..30fe268c63953 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts @@ -0,0 +1,336 @@ +/* + * 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 moment from 'moment'; + +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { + FindResponse, + SavedObjectsClient, +} from 'src/server/saved_objects/service/saved_objects_client'; +import { + CURRENT_MAJOR_VERSION, + PREV_MAJOR_VERSION, +} from 'x-pack/plugins/upgrade_assistant/common/version'; +import { + REINDEX_OP_TYPE, + ReindexOperation, + ReindexSavedObject, + ReindexStatus, + ReindexStep, +} from '../../../common/types'; +import { FlatSettings } from './types'; + +// TODO: base on elasticsearch.requestTimeout? +export const LOCK_WINDOW = moment.duration(90, 'seconds'); + +export const ML_LOCK_DOC_ID = '___ML_REINDEX_LOCK___'; + +/** + * A collection of utility functions pulled out out of the ReindexService to make testing simpler. + * This is NOT intended to be used by any other code. + */ +export interface ReindexActions { + /** + * Namespace for ML-specific actions. + */ + // ml: MlActions; + + /** + * Creates a new reindexOp, does not perform any pre-flight checks. + * @param indexName + */ + createReindexOp(indexName: string): Promise; + + /** + * Deletes a reindexOp. + * @param reindexOp + */ + deleteReindexOp(reindexOp: ReindexSavedObject): void; + + /** + * Updates a ReindexSavedObject. + * @param reindexOp + * @param attrs + */ + updateReindexOp( + reindexOp: ReindexSavedObject, + attrs?: Partial + ): Promise; + + /** + * Runs a callback function while locking the reindex operation. Guaranteed to unlock the reindex operation when complete. + * @param func A function to run with the locked ML lock document. Must return a promise that resolves + * to the updated ReindexSavedObject. + */ + runWhileLocked( + reindexOp: ReindexSavedObject, + func: (reindexOp: ReindexSavedObject) => Promise + ): Promise; + + /** + * Finds the reindex operation saved object for the given index. + * @param indexName + */ + findReindexOperations(indexName: string): Promise>; + + /** + * Returns an array of all reindex operations that have a status. + */ + findAllByStatus(status: ReindexStatus): Promise; + + /** + * Retrieve index settings (in flat, dot-notation style) and mappings. + * @param indexName + */ + getFlatSettings(indexName: string): Promise; + + // ----- Below are only for ML indices + + /** + * Atomically increments the number of reindex operations running for ML jobs. + */ + incrementMlReindexes(): Promise; + + /** + * Atomically decrements the number of reindex operations running for ML jobs. + */ + decrementMlReindexes(): Promise; + + /** + * Runs a callback function while locking the ML count. + * @param func A function to run with the locked ML lock document. Must return a promise that resolves + * to the updated ReindexSavedObject. + */ + runWhileMlLocked( + func: (mlLockDoc: ReindexSavedObject) => Promise + ): Promise; + + /** + * Exposed only for testing, DO NOT USE. + */ + _fetchAndLockMlDoc(): Promise; +} + +export const reindexActionsFactory = ( + client: SavedObjectsClient, + callCluster: CallCluster +): ReindexActions => { + // ----- Internal functions + /** + * Generates a new index name for the new index. Iterates until it finds an index + * that doesn't already exist. + * @param indexName + */ + const getNewIndexName = (indexName: string) => { + const prevVersionSuffix = `-reindexed-v${PREV_MAJOR_VERSION}`; + const currentVersionSuffix = `-reindexed-v${CURRENT_MAJOR_VERSION}`; + + if (indexName.endsWith(prevVersionSuffix)) { + return indexName.replace(new RegExp(`${prevVersionSuffix}$`), currentVersionSuffix); + } else { + return `${indexName}${currentVersionSuffix}`; + } + }; + + const isLocked = (reindexOp: ReindexSavedObject) => { + if (reindexOp.attributes.locked) { + const now = moment(); + const lockedTime = moment(reindexOp.attributes.locked); + // If the object has been locked for more than the LOCK_WINDOW, assume the process that locked it died. + if (now.subtract(LOCK_WINDOW) < lockedTime) { + return true; + } + } + + return false; + }; + + const acquireLock = async (reindexOp: ReindexSavedObject) => { + if (isLocked(reindexOp)) { + throw new Error(`Another Kibana process is currently modifying this reindex operation.`); + } + + return client.update( + REINDEX_OP_TYPE, + reindexOp.id, + { ...reindexOp.attributes, locked: moment().format() }, + { version: reindexOp.version } + ); + }; + + const releaseLock = (reindexOp: ReindexSavedObject) => { + return client.update( + REINDEX_OP_TYPE, + reindexOp.id, + { ...reindexOp.attributes, locked: null }, + { version: reindexOp.version } + ); + }; + + // ----- Public interface + return { + async createReindexOp(indexName: string) { + return client.create(REINDEX_OP_TYPE, { + indexName, + newIndexName: getNewIndexName(indexName), + status: ReindexStatus.inProgress, + lastCompletedStep: ReindexStep.created, + locked: null, + reindexTaskId: null, + reindexTaskPercComplete: null, + errorMessage: null, + mlReindexCount: null, + }); + }, + + deleteReindexOp(reindexOp: ReindexSavedObject) { + return client.delete(REINDEX_OP_TYPE, reindexOp.id); + }, + + async updateReindexOp(reindexOp: ReindexSavedObject, attrs: Partial = {}) { + if (!isLocked(reindexOp)) { + throw new Error(`ReindexOperation must be locked before updating.`); + } + + const newAttrs = { ...reindexOp.attributes, locked: moment().format(), ...attrs }; + return client.update(REINDEX_OP_TYPE, reindexOp.id, newAttrs, { + version: reindexOp.version, + }); + }, + + async runWhileLocked(reindexOp, func) { + reindexOp = await acquireLock(reindexOp); + + try { + reindexOp = await func(reindexOp); + } finally { + reindexOp = await releaseLock(reindexOp); + } + + return reindexOp; + }, + + findReindexOperations(indexName: string) { + return client.find({ + type: REINDEX_OP_TYPE, + search: `"${indexName}"`, + searchFields: ['indexName'], + }); + }, + + async findAllByStatus(status: ReindexStatus) { + const firstPage = await client.find({ + type: REINDEX_OP_TYPE, + search: status.toString(), + searchFields: ['status'], + }); + + if (firstPage.total === firstPage.saved_objects.length) { + return firstPage.saved_objects; + } + + let allOps = firstPage.saved_objects; + let page = firstPage.page + 1; + + while (allOps.length < firstPage.total) { + const nextPage = await client.find({ + type: REINDEX_OP_TYPE, + search: status.toString(), + searchFields: ['status'], + page, + }); + + allOps = [...allOps, ...nextPage.saved_objects]; + page++; + } + + return allOps; + }, + + async getFlatSettings(indexName: string) { + const flatSettings = (await callCluster('transport.request', { + path: `/${encodeURIComponent(indexName)}?flat_settings=true&include_type_name=false`, + })) as { [indexName: string]: FlatSettings }; + + if (!flatSettings[indexName]) { + return null; + } + + return flatSettings[indexName]; + }, + + async _fetchAndLockMlDoc() { + const fetchDoc = async () => { + try { + return await client.get(REINDEX_OP_TYPE, ML_LOCK_DOC_ID); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return await client.create( + REINDEX_OP_TYPE, + { + indexName: null, + newIndexName: null, + locked: null, + status: null, + lastCompletedStep: null, + reindexTaskId: null, + reindexTaskPercComplete: null, + errorMessage: null, + mlReindexCount: 0, + } as any, + { id: ML_LOCK_DOC_ID } + ); + } else { + throw e; + } + } + }; + + const lockDoc = async (attempt = 1): Promise => { + try { + // Refetch the document each time to avoid version conflicts. + return await acquireLock(await fetchDoc()); + } catch (e) { + if (attempt >= 10) { + throw new Error(`Could not acquire lock for ML jobs`); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + return lockDoc(attempt + 1); + } + }; + + return lockDoc(); + }, + + async incrementMlReindexes() { + this.runWhileMlLocked(mlDoc => + this.updateReindexOp(mlDoc, { + mlReindexCount: mlDoc.attributes.mlReindexCount! + 1, + }) + ); + }, + + async decrementMlReindexes() { + this.runWhileMlLocked(mlDoc => + this.updateReindexOp(mlDoc, { + mlReindexCount: mlDoc.attributes.mlReindexCount! - 1, + }) + ); + }, + + async runWhileMlLocked(func) { + let mlDoc = await this._fetchAndLockMlDoc(); + + try { + mlDoc = await func(mlDoc); + } finally { + await releaseLock(mlDoc); + } + }, + }; +}; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts new file mode 100644 index 0000000000000..5f299070b1171 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts @@ -0,0 +1,798 @@ +/* + * 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 { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { + ReindexOperation, + ReindexSavedObject, + ReindexStatus, + ReindexStep, +} from '../../../common/types'; +import { ReindexService, reindexServiceFactory } from './reindex_service'; + +describe('reindexService', () => { + let actions: jest.Mocked; + let callCluster: jest.Mock; + let service: ReindexService; + + const updateMockImpl = (reindexOp: ReindexSavedObject, attrs: Partial = {}) => + Promise.resolve({ + ...reindexOp, + attributes: { ...reindexOp.attributes, ...attrs }, + } as ReindexSavedObject); + + const unimplemented = (name: string) => () => + Promise.reject(`Mock function ${name} was not implemented!`); + + beforeEach(() => { + actions = { + createReindexOp: jest.fn(unimplemented('createReindexOp')), + deleteReindexOp: jest.fn(unimplemented('deleteReindexOp')), + updateReindexOp: jest.fn(updateMockImpl), + runWhileLocked: jest.fn((reindexOp: any, func: any) => func(reindexOp)), + findReindexOperations: jest.fn(unimplemented('findReindexOperations')), + findAllByStatus: jest.fn(unimplemented('findAllInProgressOperations')), + getFlatSettings: jest.fn(unimplemented('getFlatSettings')), + cleanupChanges: jest.fn(), + incrementMlReindexes: jest.fn(unimplemented('incrementMlReindexes')), + decrementMlReindexes: jest.fn(unimplemented('decrementMlReindexes')), + runWhileMlLocked: jest.fn(async (f: any) => f({ attributes: {} })), + }; + callCluster = jest.fn(); + service = reindexServiceFactory(callCluster, actions); + }); + + describe('detectReindexWarnings', () => { + it('fetches reindex warnings from flat settings', async () => { + actions.getFlatSettings.mockResolvedValueOnce({ + settings: {}, + mappings: { + properties: { https: { type: 'boolean' } }, + }, + }); + + const reindexWarnings = await service.detectReindexWarnings('myIndex'); + expect(reindexWarnings).toEqual([]); + }); + + it('returns null if index does not exist', async () => { + actions.getFlatSettings.mockResolvedValueOnce(null); + const reindexWarnings = await service.detectReindexWarnings('myIndex'); + expect(reindexWarnings).toBeNull(); + }); + }); + + describe('createReindexOperation', () => { + it('creates new reindex operation', async () => { + callCluster.mockResolvedValueOnce(true); // indices.exist + actions.findReindexOperations.mockResolvedValueOnce({ total: 0 }); + actions.createReindexOp.mockResolvedValueOnce(); + + await service.createReindexOperation('myIndex'); + + expect(actions.createReindexOp).toHaveBeenCalledWith('myIndex'); + }); + + it('fails if index does not exist', async () => { + callCluster.mockResolvedValueOnce(false); // indices.exist + await expect(service.createReindexOperation('myIndex')).rejects.toThrow(); + expect(actions.createReindexOp).not.toHaveBeenCalled(); + }); + + it('deletes existing operation if it failed', async () => { + callCluster.mockResolvedValueOnce(true); // indices.exist + actions.findReindexOperations.mockResolvedValueOnce({ + saved_objects: [{ id: 1, attributes: { status: ReindexStatus.failed } }], + total: 1, + }); + actions.deleteReindexOp.mockResolvedValueOnce(); + actions.createReindexOp.mockResolvedValueOnce(); + + await service.createReindexOperation('myIndex'); + expect(actions.deleteReindexOp).toHaveBeenCalledWith({ + id: 1, + attributes: { status: ReindexStatus.failed }, + }); + }); + + it('fails if existing operation did not fail', async () => { + callCluster.mockResolvedValueOnce(true); // indices.exist + actions.findReindexOperations.mockResolvedValueOnce({ + saved_objects: [{ id: 1, attributes: { status: ReindexStatus.inProgress } }], + total: 1, + }); + + await expect(service.createReindexOperation('myIndex')).rejects.toThrow(); + expect(actions.deleteReindexOp).not.toHaveBeenCalled(); + expect(actions.createReindexOp).not.toHaveBeenCalled(); + }); + }); + + describe('findReindexOperation', () => { + it('returns the only result', async () => { + actions.findReindexOperations.mockResolvedValue({ total: 1, saved_objects: ['fake object'] }); + await expect(service.findReindexOperation('myIndex')).resolves.toEqual('fake object'); + }); + + it('returns null if there are no results', async () => { + actions.findReindexOperations.mockResolvedValue({ total: 0 }); + await expect(service.findReindexOperation('myIndex')).resolves.toBeNull(); + }); + + it('fails if there is more than 1 result', async () => { + actions.findReindexOperations.mockResolvedValue({ total: 2 }); + await expect(service.findReindexOperation('myIndex')).rejects.toThrow(); + }); + }); + + describe('processNextStep', () => { + describe('locking', () => { + // These tests depend on an implementation detail that if no status is set, the state machine + // is not activated, just the locking mechanism. + + it('runs with runWhileLocked', async () => { + const reindexOp = { id: '1', attributes: { locked: null } } as ReindexSavedObject; + await service.processNextStep(reindexOp); + + expect(actions.runWhileLocked).toHaveBeenCalled(); + }); + }); + }); + + describe('pauseReindexOperation', () => { + it('runs with runWhileLocked', async () => { + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce({ + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.inProgress }, + }); + + await service.pauseReindexOperation('myIndex'); + + expect(actions.runWhileLocked).toHaveBeenCalled(); + findSpy.mockRestore(); + }); + + it('sets the status to paused', async () => { + const reindexOp = { + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.inProgress }, + } as ReindexSavedObject; + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce(reindexOp); + + await expect(service.pauseReindexOperation('myIndex')).resolves.toEqual({ + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.paused }, + }); + + expect(findSpy).toHaveBeenCalledWith('myIndex'); + expect(actions.updateReindexOp).toHaveBeenCalledWith(reindexOp, { + status: ReindexStatus.paused, + }); + findSpy.mockRestore(); + }); + + it('throws if reindexOp is not inProgress', async () => { + const reindexOp = { + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.failed }, + } as ReindexSavedObject; + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce(reindexOp); + + await expect(service.pauseReindexOperation('myIndex')).rejects.toThrow(); + expect(actions.updateReindexOp).not.toHaveBeenCalled(); + findSpy.mockRestore(); + }); + + it('throws in reindex operation does not exist', async () => { + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce(null); + await expect(service.pauseReindexOperation('myIndex')).rejects.toThrow(); + expect(actions.updateReindexOp).not.toHaveBeenCalled(); + findSpy.mockRestore(); + }); + }); + + describe('resumeReindexOperation', () => { + it('runs with runWhileLocked', async () => { + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce({ + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.paused }, + }); + + await service.resumeReindexOperation('myIndex'); + + expect(actions.runWhileLocked).toHaveBeenCalled(); + findSpy.mockRestore(); + }); + + it('sets the status to inProgress', async () => { + const reindexOp = { + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.paused }, + } as ReindexSavedObject; + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce(reindexOp); + + await expect(service.resumeReindexOperation('myIndex')).resolves.toEqual({ + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.inProgress }, + }); + + expect(findSpy).toHaveBeenCalledWith('myIndex'); + expect(actions.updateReindexOp).toHaveBeenCalledWith(reindexOp, { + status: ReindexStatus.inProgress, + }); + findSpy.mockRestore(); + }); + + it('throws if reindexOp is not inProgress', async () => { + const reindexOp = { + id: '2', + attributes: { indexName: 'myIndex', status: ReindexStatus.failed }, + } as ReindexSavedObject; + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce(reindexOp); + + await expect(service.resumeReindexOperation('myIndex')).rejects.toThrow(); + expect(actions.updateReindexOp).not.toHaveBeenCalled(); + findSpy.mockRestore(); + }); + + it('throws in reindex operation does not exist', async () => { + const findSpy = jest.spyOn(service, 'findReindexOperation').mockResolvedValueOnce(null); + await expect(service.resumeReindexOperation('myIndex')).rejects.toThrow(); + expect(actions.updateReindexOp).not.toHaveBeenCalled(); + findSpy.mockRestore(); + }); + }); + + describe('state machine, lastCompletedStep ===', () => { + const defaultAttributes = { + indexName: 'myIndex', + newIndexName: 'myIndex-reindex-0', + status: ReindexStatus.inProgress, + }; + const settingsMappings = { + settings: { 'index.number_of_replicas': 7, 'index.blocks.write': true }, + mappings: { _doc: { properties: { timestampl: { type: 'date' } } } }, + }; + + describe('created', () => { + const reindexOp = { + id: '1', + attributes: { ...defaultAttributes, lastCompletedStep: ReindexStep.created }, + } as ReindexSavedObject; + + // ML + const mlReindexOp = { + id: '2', + attributes: { ...reindexOp.attributes, indexName: '.ml-anomalies' }, + } as ReindexSavedObject; + + it('does nothing if index is not an ML index', async () => { + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStopped); + expect(actions.incrementMlReindexes).not.toHaveBeenCalled(); + expect(actions.runWhileMlLocked).not.toHaveBeenCalled(); + expect(callCluster).not.toHaveBeenCalled(); + }); + + it('increments ML reindexes and calls ML stop endpoint', async () => { + actions.incrementMlReindexes.mockResolvedValueOnce(); + actions.runWhileMlLocked.mockImplementationOnce(async (f: any) => f()); + callCluster + // Mock call to /_nodes for version check + .mockResolvedValueOnce({ nodes: { nodeX: { version: '6.7.0-alpha' } } }) + // Mock call to /_ml/set_upgrade_mode?enabled=true + .mockResolvedValueOnce({ acknowledged: true }); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStopped); + expect(actions.incrementMlReindexes).toHaveBeenCalled(); + expect(actions.runWhileMlLocked).toHaveBeenCalled(); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=true', + method: 'POST', + }); + }); + + it('fails if ML reindexes cannot be incremented', async () => { + actions.incrementMlReindexes.mockRejectedValueOnce(new Error(`Can't lock!`)); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.created); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage!.includes(`Can't lock!`)).toBeTruthy(); + expect(callCluster).not.toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=true', + method: 'POST', + }); + }); + + it('fails if ML doc cannot be locked', async () => { + actions.incrementMlReindexes.mockResolvedValueOnce(); + actions.runWhileMlLocked.mockRejectedValueOnce(new Error(`Can't lock!`)); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.created); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage!.includes(`Can't lock!`)).toBeTruthy(); + expect(callCluster).not.toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=true', + method: 'POST', + }); + }); + + it('fails if ML endpoint fails', async () => { + actions.incrementMlReindexes.mockResolvedValueOnce(); + callCluster + // Mock call to /_nodes for version check + .mockResolvedValueOnce({ nodes: { nodeX: { version: '6.7.0' } } }) + // Mock call to /_ml/set_upgrade_mode?enabled=true + .mockResolvedValueOnce({ acknowledged: false }); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.created); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage!.includes('Could not stop ML jobs')).toBeTruthy(); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=true', + method: 'POST', + }); + }); + + it('fails if not all nodes have been upgraded to 6.7.0', async () => { + actions.incrementMlReindexes.mockResolvedValueOnce(); + callCluster + // Mock call to /_nodes for version check + .mockResolvedValueOnce({ nodes: { nodeX: { version: '6.6.0' } } }); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.created); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect( + updatedOp.attributes.errorMessage!.includes('Some nodes are not on minimum version') + ).toBeTruthy(); + // Should not have called ML endpoint at all + expect(callCluster).not.toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=true', + method: 'POST', + }); + }); + + // Watcher + const watcherReindexOp = { + id: '2', + attributes: { ...reindexOp.attributes, indexName: '.watches' }, + } as ReindexSavedObject; + + it('does nothing if index is not a watcher index', async () => { + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStopped); + expect(callCluster).not.toHaveBeenCalled(); + }); + + it('calls watcher start endpoint', async () => { + callCluster.mockResolvedValueOnce({ acknowledged: true }); + const updatedOp = await service.processNextStep(watcherReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStopped); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_watcher/_stop', + method: 'POST', + }); + }); + + it('fails if watcher start endpoint fails', async () => { + callCluster.mockResolvedValueOnce({ acknowledged: false }); + const updatedOp = await service.processNextStep(watcherReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.created); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_watcher/_stop', + method: 'POST', + }); + }); + + it('fails if watcher start endpoint throws', async () => { + callCluster.mockRejectedValueOnce(new Error('Whoops!')); + const updatedOp = await service.processNextStep(watcherReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.created); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_watcher/_stop', + method: 'POST', + }); + }); + }); + + describe('indexConsumersStopped', () => { + const reindexOp = { + id: '1', + attributes: { ...defaultAttributes, lastCompletedStep: ReindexStep.indexConsumersStopped }, + } as ReindexSavedObject; + + it('blocks writes and updates lastCompletedStep', async () => { + callCluster.mockResolvedValueOnce({ acknowledged: true }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.readonly); + expect(callCluster).toHaveBeenCalledWith('indices.putSettings', { + index: 'myIndex', + body: { 'index.blocks.write': true }, + }); + }); + + it('fails if setting updates are not acknowledged', async () => { + callCluster.mockResolvedValueOnce({ acknowledged: false }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStopped); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + }); + + it('fails if setting updates fail', async () => { + callCluster.mockRejectedValueOnce(new Error('blah!')); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStopped); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + }); + }); + + describe('readonly', () => { + const reindexOp = { + id: '1', + attributes: { ...defaultAttributes, lastCompletedStep: ReindexStep.readonly }, + } as ReindexSavedObject; + + // The more intricate details of how the settings are chosen are test separately. + it('creates new index with settings and mappings and updates lastCompletedStep', async () => { + actions.getFlatSettings.mockResolvedValueOnce(settingsMappings); + callCluster.mockResolvedValueOnce({ acknowledged: true }); // indices.create + + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.newIndexCreated); + expect(callCluster).toHaveBeenCalledWith('indices.create', { + index: 'myIndex-reindex-0', + body: { + // index.blocks.write should be removed from the settings for the new index. + settings: { 'index.number_of_replicas': 7 }, + mappings: settingsMappings.mappings, + }, + }); + }); + + it('fails if create index is not acknowledged', async () => { + callCluster + .mockResolvedValueOnce({ myIndex: settingsMappings }) + .mockResolvedValueOnce({ acknowledged: false }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.readonly); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + }); + + it('fails if create index fails', async () => { + callCluster + .mockResolvedValueOnce({ myIndex: settingsMappings }) + .mockRejectedValueOnce(new Error(`blah!`)) + .mockResolvedValueOnce({ acknowledged: true }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.readonly); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + + // Original index should have been set back to allow reads. + expect(callCluster).toHaveBeenCalledWith('indices.putSettings', { + index: 'myIndex', + body: { 'index.blocks.write': false }, + }); + }); + }); + + describe('newIndexCreated', () => { + const reindexOp = { + id: '1', + attributes: { ...defaultAttributes, lastCompletedStep: ReindexStep.newIndexCreated }, + } as ReindexSavedObject; + + it('starts reindex, saves taskId, and updates lastCompletedStep', async () => { + callCluster.mockResolvedValueOnce({ task: 'xyz' }); // reindex + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.reindexStarted); + expect(updatedOp.attributes.reindexTaskId).toEqual('xyz'); + expect(updatedOp.attributes.reindexTaskPercComplete).toEqual(0); + expect(callCluster).toHaveBeenLastCalledWith('reindex', { + refresh: true, + waitForCompletion: false, + body: { + source: { index: 'myIndex' }, + dest: { index: 'myIndex-reindex-0' }, + }, + }); + }); + + it('fails if starting reindex fails', async () => { + callCluster.mockRejectedValueOnce(new Error('blah!')).mockResolvedValueOnce({}); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.newIndexCreated); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + }); + }); + + describe('reindexStarted', () => { + const reindexOp = { + id: '1', + attributes: { + ...defaultAttributes, + lastCompletedStep: ReindexStep.reindexStarted, + reindexTaskId: 'xyz', + }, + } as ReindexSavedObject; + + describe('reindex task is not complete', () => { + it('updates reindexTaskPercComplete', async () => { + callCluster.mockResolvedValueOnce({ + completed: false, + task: { status: { created: 10, total: 100 } }, + }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.reindexStarted); + expect(updatedOp.attributes.reindexTaskPercComplete).toEqual(0.1); // 10 / 100 = 0.1 + }); + }); + + describe('reindex task is complete', () => { + it('deletes task, updates reindexTaskPercComplete, updates lastCompletedStep', async () => { + callCluster + .mockResolvedValueOnce({ + completed: true, + task: { status: { created: 100, total: 100 } }, + }) + .mockResolvedValueOnce({ count: 100 }) + .mockResolvedValueOnce({ result: 'deleted' }); + + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.reindexCompleted); + expect(updatedOp.attributes.reindexTaskPercComplete).toEqual(1); + expect(callCluster).toHaveBeenCalledWith('delete', { + index: '.tasks', + type: 'task', + id: 'xyz', + }); + }); + + it('fails if docs created is less than count in source index', async () => { + callCluster + .mockResolvedValueOnce({ + completed: true, + task: { status: { created: 95, total: 95 } }, + }) + .mockReturnValueOnce({ count: 100 }); + + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.reindexStarted); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + }); + }); + }); + + describe('reindexCompleted', () => { + const reindexOp = { + id: '1', + attributes: { ...defaultAttributes, lastCompletedStep: ReindexStep.reindexCompleted }, + } as ReindexSavedObject; + + it('switches aliases, sets as complete, and updates lastCompletedStep', async () => { + callCluster + .mockResolvedValueOnce({ myIndex: { aliases: {} } }) + .mockResolvedValueOnce({ acknowledged: true }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.aliasCreated); + expect(callCluster).toHaveBeenCalledWith('indices.updateAliases', { + body: { + actions: [ + { add: { index: 'myIndex-reindex-0', alias: 'myIndex' } }, + { remove_index: { index: 'myIndex' } }, + ], + }, + }); + }); + + it('moves existing aliases over to new index', async () => { + callCluster + .mockResolvedValueOnce({ + myIndex: { + aliases: { + myAlias: {}, + myFilteredAlias: { filter: { term: { https: true } } }, + }, + }, + }) + .mockResolvedValueOnce({ acknowledged: true }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.aliasCreated); + expect(callCluster).toHaveBeenCalledWith('indices.updateAliases', { + body: { + actions: [ + { add: { index: 'myIndex-reindex-0', alias: 'myIndex' } }, + { remove_index: { index: 'myIndex' } }, + { add: { index: 'myIndex-reindex-0', alias: 'myAlias' } }, + { + add: { + index: 'myIndex-reindex-0', + alias: 'myFilteredAlias', + filter: { term: { https: true } }, + }, + }, + ], + }, + }); + }); + + it('fails if switching aliases is not acknowledged', async () => { + callCluster.mockResolvedValueOnce({ acknowledged: false }); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.reindexCompleted); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + }); + + it('fails if switching aliases fails', async () => { + callCluster.mockRejectedValueOnce(new Error('blah!')); + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.reindexCompleted); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage).not.toBeNull(); + }); + }); + + describe('aliasCreated', () => { + const reindexOp = { + id: '1', + attributes: { ...defaultAttributes, lastCompletedStep: ReindexStep.aliasCreated }, + } as ReindexSavedObject; + + // ML + const mlReindexOp = { + id: '2', + attributes: { ...reindexOp.attributes, indexName: '.ml-anomalies' }, + } as ReindexSavedObject; + + it('does nothing if index is not an ML index', async () => { + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStarted); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.completed); + expect(callCluster).not.toHaveBeenCalled(); + }); + + it('decrements ML reindexes and calls ML start endpoint if no remaining ML jobs', async () => { + actions.decrementMlReindexes.mockResolvedValue(); + actions.runWhileMlLocked.mockImplementationOnce(async (f: any) => + f({ attributes: { mlReindexCount: 0 } }) + ); + // Mock call to /_ml/set_upgrade_mode?enabled=false + callCluster.mockResolvedValueOnce({ acknowledged: true }); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStarted); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=false', + method: 'POST', + }); + }); + + it('does not call ML start endpoint if there are remaining ML jobs', async () => { + actions.decrementMlReindexes.mockResolvedValue(); + actions.runWhileMlLocked.mockImplementationOnce(async (f: any) => + f({ attributes: { mlReindexCount: 2 } }) + ); + // Mock call to /_ml/set_upgrade_mode?enabled=false + callCluster.mockResolvedValueOnce({ acknowledged: true }); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStarted); + expect(callCluster).not.toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=false', + method: 'POST', + }); + }); + + it('fails if ML reindexes cannot be decremented', async () => { + // Mock unable to lock ml doc + actions.decrementMlReindexes.mockRejectedValue(new Error(`Can't lock!`)); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.aliasCreated); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage!.includes(`Can't lock!`)).toBeTruthy(); + expect(callCluster).not.toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=false', + method: 'POST', + }); + }); + + it('fails if ML doc cannot be locked', async () => { + actions.decrementMlReindexes.mockResolvedValue(); + // Mock unable to lock ml doc + actions.runWhileMlLocked.mockRejectedValueOnce(new Error(`Can't lock!`)); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.aliasCreated); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(updatedOp.attributes.errorMessage!.includes(`Can't lock!`)).toBeTruthy(); + expect(callCluster).not.toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=false', + method: 'POST', + }); + }); + + it('fails if ML endpoint fails', async () => { + actions.decrementMlReindexes.mockResolvedValue(); + actions.runWhileMlLocked.mockImplementationOnce(async (f: any) => + f({ attributes: { mlReindexCount: 0 } }) + ); + // Mock call to /_ml/set_upgrade_mode?enabled=true + callCluster.mockResolvedValueOnce({ acknowledged: false }); + + const updatedOp = await service.processNextStep(mlReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.aliasCreated); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect( + updatedOp.attributes.errorMessage!.includes('Could not resume ML jobs') + ).toBeTruthy(); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=false', + method: 'POST', + }); + }); + + // Watcher + const watcherReindexOp = { + id: '2', + attributes: { ...reindexOp.attributes, indexName: '.watches' }, + } as ReindexSavedObject; + + it('does nothing if index is not a watcher index', async () => { + const updatedOp = await service.processNextStep(reindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStarted); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.completed); + expect(callCluster).not.toHaveBeenCalled(); + }); + + it('calls watcher start endpoint', async () => { + callCluster.mockResolvedValueOnce({ acknowledged: true }); + const updatedOp = await service.processNextStep(watcherReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.indexConsumersStarted); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.completed); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_watcher/_start', + method: 'POST', + }); + }); + + it('fails if watcher start endpoint fails', async () => { + callCluster.mockResolvedValueOnce({ acknowledged: false }); + const updatedOp = await service.processNextStep(watcherReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.aliasCreated); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_watcher/_start', + method: 'POST', + }); + }); + + it('fails if watcher start endpoint throws', async () => { + callCluster.mockRejectedValueOnce(new Error('Whoops!')); + const updatedOp = await service.processNextStep(watcherReindexOp); + expect(updatedOp.attributes.lastCompletedStep).toEqual(ReindexStep.aliasCreated); + expect(updatedOp.attributes.status).toEqual(ReindexStatus.failed); + expect(callCluster).toHaveBeenCalledWith('transport.request', { + path: '/_watcher/_start', + method: 'POST', + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts new file mode 100644 index 0000000000000..ce285ff22433b --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -0,0 +1,502 @@ +/* + * 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 Boom from 'boom'; + +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { + ReindexSavedObject, + ReindexStatus, + ReindexStep, + ReindexWarning, +} from '../../../common/types'; +import { getReindexWarnings, transformFlatSettings } from './index_settings'; +import { ReindexActions } from './reindex_actions'; + +const VERSION_REGEX = new RegExp(/^([1-9]+)\.([0-9]+)\.([0-9]+)/); + +export interface ReindexService { + /** + * Checks an index's settings and mappings to flag potential issues during reindex. + * Resolves to null if index does not exist. + * @param indexName + */ + detectReindexWarnings(indexName: string): Promise; + + /** + * Creates a new reindex operation for a given index. + * @param indexName + */ + createReindexOperation(indexName: string): Promise; + + /** + * Retrieves all reindex operations that have the given status. + * @param status + */ + findAllByStatus(status: ReindexStatus): Promise; + + /** + * Finds the reindex operation for the given index. + * Resolves to null if there is no existing reindex operation for this index. + * @param indexName + */ + findReindexOperation(indexName: string): Promise; + + /** + * Process the reindex operation through one step of the state machine and resolves + * to the updated reindex operation. + * @param reindexOp + */ + processNextStep(reindexOp: ReindexSavedObject): Promise; + + /** + * Pauses the in-progress reindex operation for a given index. + * @param indexName + */ + pauseReindexOperation(indexName: string): Promise; + + /** + * Resumes the paused reindex operation for a given index. + * @param indexName + */ + resumeReindexOperation(indexName: string): Promise; +} + +export const reindexServiceFactory = ( + callCluster: CallCluster, + actions: ReindexActions +): ReindexService => { + // ------ Utility functions + + /** + * If the index is a ML index that will cause jobs to fail when set to readonly, + * turn on 'upgrade mode' to pause all ML jobs. + * @param reindexOp + */ + const stopMlJobs = async () => { + await actions.incrementMlReindexes(); + await actions.runWhileMlLocked(async mlDoc => { + await validateNodesMinimumVersion(6, 7); + + const res = await callCluster('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=true', + method: 'POST', + }); + + if (!res.acknowledged) { + throw new Error(`Could not stop ML jobs`); + } + + return mlDoc; + }); + }; + + /** + * Resumes ML jobs if there are no more remaining reindex operations. + */ + const resumeMlJobs = async () => { + await actions.decrementMlReindexes(); + await actions.runWhileMlLocked(async mlDoc => { + if (mlDoc.attributes.mlReindexCount === 0) { + const res = await callCluster('transport.request', { + path: '/_ml/set_upgrade_mode?enabled=false', + method: 'POST', + }); + + if (!res.acknowledged) { + throw new Error(`Could not resume ML jobs`); + } + } + + return mlDoc; + }); + }; + + /** + * Stops Watcher in Elasticsearch. + */ + const stopWatcher = async () => { + const { acknowledged } = await callCluster('transport.request', { + path: '/_watcher/_stop', + method: 'POST', + }); + + if (!acknowledged) { + throw new Error('Could not stop Watcher'); + } + }; + + /** + * Starts Watcher in Elasticsearch. + */ + const startWatcher = async () => { + const { acknowledged } = await callCluster('transport.request', { + path: '/_watcher/_start', + method: 'POST', + }); + + if (!acknowledged) { + throw new Error('Could not start Watcher'); + } + }; + + const cleanupChanges = async (reindexOp: ReindexSavedObject) => { + // Set back to writable if we ever got past this point. + if (reindexOp.attributes.lastCompletedStep >= ReindexStep.readonly) { + await callCluster('indices.putSettings', { + index: reindexOp.attributes.indexName, + body: { 'index.blocks.write': false }, + }); + } + + // Stop consumers if we ever got past this point. + if (reindexOp.attributes.lastCompletedStep >= ReindexStep.indexConsumersStopped) { + await resumeConsumers(reindexOp); + } + }; + + // ------ Functions used to process the state machine + + const validateNodesMinimumVersion = async (minMajor: number, minMinor: number) => { + const nodesResponse = await callCluster('transport.request', { + path: '/_nodes', + method: 'GET', + }); + + const outDatedNodes = Object.values(nodesResponse.nodes).filter((node: any) => { + const matches = node.version.match(VERSION_REGEX); + const major = parseInt(matches[1], 10); + const minor = parseInt(matches[2], 10); + + // All ES nodes must be >= 6.7.0 to pause ML jobs + return !(major > minMajor || (major === minMajor && minor >= minMinor)); + }); + + if (outDatedNodes.length > 0) { + const nodeList = JSON.stringify(outDatedNodes.map((n: any) => n.name)); + throw new Error( + `Some nodes are not on minimum version (${minMajor}.${minMinor}.0) required: ${nodeList}` + ); + } + }; + + const stopConsumers = async (reindexOp: ReindexSavedObject) => { + if (isMlIndex(reindexOp.attributes.indexName)) { + await stopMlJobs(); + } else if (isWatcherIndex(reindexOp.attributes.indexName)) { + await stopWatcher(); + } + + return actions.updateReindexOp(reindexOp, { + lastCompletedStep: ReindexStep.indexConsumersStopped, + }); + }; + + /** + * Sets the original index as readonly so new data can be indexed until the reindex + * is completed. + * @param reindexOp + */ + const setReadonly = async (reindexOp: ReindexSavedObject) => { + const { indexName } = reindexOp.attributes; + const putReadonly = await callCluster('indices.putSettings', { + index: indexName, + body: { 'index.blocks.write': true }, + }); + + if (!putReadonly.acknowledged) { + throw new Error(`Index could not be set to readonly.`); + } + + return actions.updateReindexOp(reindexOp, { lastCompletedStep: ReindexStep.readonly }); + }; + + /** + * Creates a new index with the same mappings and settings as the original index. + * @param reindexOp + */ + const createNewIndex = async (reindexOp: ReindexSavedObject) => { + const { indexName, newIndexName } = reindexOp.attributes; + + const flatSettings = await actions.getFlatSettings(indexName); + if (!flatSettings) { + throw Boom.notFound(`Index ${indexName} does not exist.`); + } + + const { settings, mappings } = transformFlatSettings(flatSettings); + const createIndex = await callCluster('indices.create', { + index: newIndexName, + body: { + settings, + mappings, + }, + }); + + if (!createIndex.acknowledged) { + throw Boom.badImplementation(`Index could not be created: ${newIndexName}`); + } + + return actions.updateReindexOp(reindexOp, { + lastCompletedStep: ReindexStep.newIndexCreated, + }); + }; + + /** + * Begins the reindex process via Elasticsearch's Reindex API. + * @param reindexOp + */ + const startReindexing = async (reindexOp: ReindexSavedObject) => { + const { indexName } = reindexOp.attributes; + const startReindex = (await callCluster('reindex', { + refresh: true, + waitForCompletion: false, + body: { + source: { index: indexName }, + dest: { index: reindexOp.attributes.newIndexName }, + }, + })) as any; + + return actions.updateReindexOp(reindexOp, { + lastCompletedStep: ReindexStep.reindexStarted, + reindexTaskId: startReindex.task, + reindexTaskPercComplete: 0, + }); + }; + + /** + * Polls Elasticsearch's Tasks API to see if the reindex operation has been completed. + * @param reindexOp + */ + const updateReindexStatus = async (reindexOp: ReindexSavedObject) => { + const taskId = reindexOp.attributes.reindexTaskId; + + // Check reindexing task progress + const taskResponse = await callCluster('tasks.get', { + taskId, + waitForCompletion: false, + }); + + if (taskResponse.completed) { + const { count } = await callCluster('count', { index: reindexOp.attributes.indexName }); + if (taskResponse.task.status.created < count) { + if (taskResponse.response.failures && taskResponse.response.failures.length > 0) { + const failureExample = JSON.stringify(taskResponse.response.failures[0]); + throw Boom.badData(`Reindexing failed with failures like: ${failureExample}`); + } else { + throw Boom.badData('Reindexing failed due to new documents created in original index.'); + } + } + + // Delete the task from ES .tasks index + const deleteTaskResp = await callCluster('delete', { + index: '.tasks', + type: 'task', + id: taskId, + }); + + if (deleteTaskResp.result !== 'deleted') { + throw Boom.badImplementation(`Could not delete reindexing task ${taskId}`); + } + + // Update the status + return actions.updateReindexOp(reindexOp, { + lastCompletedStep: ReindexStep.reindexCompleted, + reindexTaskPercComplete: 1, + }); + } else { + const perc = taskResponse.task.status.created / taskResponse.task.status.total; + return actions.updateReindexOp(reindexOp, { + reindexTaskPercComplete: perc, + }); + } + }; + + /** + * Creates an alias that points the old index to the new index, deletes the old index. + * @param reindexOp + */ + const switchAlias = async (reindexOp: ReindexSavedObject) => { + const { indexName, newIndexName } = reindexOp.attributes; + + const existingAliases = (await callCluster('indices.getAlias', { + index: indexName, + }))[indexName].aliases; + + const extraAlises = Object.keys(existingAliases).map(aliasName => ({ + add: { index: newIndexName, alias: aliasName, ...existingAliases[aliasName] }, + })); + + const aliasResponse = await callCluster('indices.updateAliases', { + body: { + actions: [ + { add: { index: newIndexName, alias: indexName } }, + { remove_index: { index: indexName } }, + ...extraAlises, + ], + }, + }); + + if (!aliasResponse.acknowledged) { + throw Boom.badImplementation(`Index aliases could not be created.`); + } + + return actions.updateReindexOp(reindexOp, { + lastCompletedStep: ReindexStep.aliasCreated, + }); + }; + + const resumeConsumers = async (reindexOp: ReindexSavedObject) => { + if (isMlIndex(reindexOp.attributes.indexName)) { + await resumeMlJobs(); + } else if (isWatcherIndex(reindexOp.attributes.indexName)) { + await startWatcher(); + } + + // Only change the status if we're still in-progress (this function is also called when the reindex fails) + if (reindexOp.attributes.status === ReindexStatus.inProgress) { + return actions.updateReindexOp(reindexOp, { + lastCompletedStep: ReindexStep.indexConsumersStarted, + status: ReindexStatus.completed, + }); + } else { + return reindexOp; + } + }; + + // ------ The service itself + + return { + async detectReindexWarnings(indexName: string) { + const flatSettings = await actions.getFlatSettings(indexName); + if (!flatSettings) { + return null; + } else { + return getReindexWarnings(flatSettings); + } + }, + + async createReindexOperation(indexName: string) { + const indexExists = await callCluster('indices.exists', { index: indexName }); + if (!indexExists) { + throw Boom.notFound(`Index ${indexName} does not exist in this cluster.`); + } + + const existingReindexOps = await actions.findReindexOperations(indexName); + if (existingReindexOps.total !== 0) { + const existingOp = existingReindexOps.saved_objects[0]; + if (existingOp.attributes.status === ReindexStatus.failed) { + // Delete the existing one if it failed to give a chance to retry. + await actions.deleteReindexOp(existingOp); + } else { + throw Boom.badImplementation(`A reindex operation already in-progress for ${indexName}`); + } + } + + return actions.createReindexOp(indexName); + }, + + async findReindexOperation(indexName: string) { + const findResponse = await actions.findReindexOperations(indexName); + + // Bail early if it does not exist or there is more than one. + if (findResponse.total === 0) { + return null; + } else if (findResponse.total > 1) { + throw Boom.badImplementation(`More than one reindex operation found for ${indexName}`); + } + + return findResponse.saved_objects[0]; + }, + + findAllByStatus: actions.findAllByStatus, + + async processNextStep(reindexOp: ReindexSavedObject) { + return actions.runWhileLocked(reindexOp, async lockedReindexOp => { + try { + switch (lockedReindexOp.attributes.lastCompletedStep) { + case ReindexStep.created: + lockedReindexOp = await stopConsumers(lockedReindexOp); + break; + case ReindexStep.indexConsumersStopped: + lockedReindexOp = await setReadonly(lockedReindexOp); + break; + case ReindexStep.readonly: + lockedReindexOp = await createNewIndex(lockedReindexOp); + break; + case ReindexStep.newIndexCreated: + lockedReindexOp = await startReindexing(lockedReindexOp); + break; + case ReindexStep.reindexStarted: + lockedReindexOp = await updateReindexStatus(lockedReindexOp); + break; + case ReindexStep.reindexCompleted: + lockedReindexOp = await switchAlias(lockedReindexOp); + break; + case ReindexStep.aliasCreated: + lockedReindexOp = await resumeConsumers(lockedReindexOp); + break; + default: + break; + } + } catch (e) { + // Trap the exception and add the message to the object so the UI can display it. + lockedReindexOp = await actions.updateReindexOp(lockedReindexOp, { + status: ReindexStatus.failed, + errorMessage: e instanceof Error ? e.stack : e.toString(), + }); + + // Cleanup any changes, ignoring any errors. + await cleanupChanges(lockedReindexOp).catch(e => undefined); + } + + return lockedReindexOp; + }); + }, + + async pauseReindexOperation(indexName: string) { + const reindexOp = await this.findReindexOperation(indexName); + + if (!reindexOp) { + throw new Error(`No reindex operation found for index ${indexName}`); + } + + return actions.runWhileLocked(reindexOp, async op => { + if (op.attributes.status === ReindexStatus.paused) { + // Another node already paused the operation, don't do anything + return reindexOp; + } else if (op.attributes.status !== ReindexStatus.inProgress) { + throw new Error(`Reindex operation must be inProgress in order to be paused.`); + } + + return actions.updateReindexOp(op, { status: ReindexStatus.paused }); + }); + }, + + async resumeReindexOperation(indexName: string) { + const reindexOp = await this.findReindexOperation(indexName); + + if (!reindexOp) { + throw new Error(`No reindex operation found for index ${indexName}`); + } + + return actions.runWhileLocked(reindexOp, async op => { + if (op.attributes.status === ReindexStatus.inProgress) { + // Another node already resumed the operation, don't do anything + return reindexOp; + } else if (op.attributes.status !== ReindexStatus.paused) { + throw new Error(`Reindex operation must be paused in order to be resumed.`); + } + + return actions.updateReindexOp(op, { status: ReindexStatus.inProgress }); + }); + }, + }; +}; + +const isMlIndex = (indexName: string) => + indexName.startsWith('.ml-state') || indexName.startsWith('.ml-anomalies'); + +const isWatcherIndex = (indexName: string) => indexName.startsWith('.watches'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts new file mode 100644 index 0000000000000..72243de7c013c --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface Mapping { + type?: string; + properties?: MappingProperties; +} + +export interface MappingProperties { + [key: string]: Mapping; +} + +export interface FlatSettings { + settings: { + [key: string]: string; + }; + mappings: { + properties?: MappingProperties; + }; +} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts new file mode 100644 index 0000000000000..960704d15db68 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { CallCluster, CallClusterWithRequest } from 'src/legacy/core_plugins/elasticsearch'; +import { Request, Server } from 'src/server/kbn_server'; +import { SavedObjectsClient } from 'src/server/saved_objects'; + +import moment = require('moment'); +import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; +import { CredentialStore } from './credential_store'; +import { reindexActionsFactory } from './reindex_actions'; +import { ReindexService, reindexServiceFactory } from './reindex_service'; + +const POLL_INTERVAL = 30000; +// If no nodes have been able to update this index in 2 minutes (due to missing credentials), set to paused. +const PAUSE_WINDOW = POLL_INTERVAL * 4; + +const LOG_TAGS = ['upgrade_assistant', 'reindex_worker']; + +/** + * A singleton worker that will coordinate two polling loops: + * (1) A longer loop that polls for reindex operations that are in progress. If any are found, loop (2) is started. + * (2) A tighter loop that pushes each in progress reindex operation through ReindexService.processNextStep. If all + * updated reindex operations are complete, this loop will terminate. + * + * The worker can also be forced to start loop (2) by calling forceRefresh(). This is done when we know a new reindex + * operation has been started. + * + * This worker can be ran on multiple nodes without conflicts or dropped jobs. Reindex operations are locked by the + * ReindexService and if any operation is locked longer than the ReindexService's timeout, it is assumed to have been + * locked by a node that is no longer running (crashed or shutdown). In this case, another node may safely acquire + * the lock for this reindex operation. + */ +export class ReindexWorker { + private static workerSingleton?: ReindexWorker; + private continuePolling: boolean = false; + private updateOperationLoopRunning: boolean = false; + private inProgressOps: ReindexSavedObject[] = []; + private readonly reindexService: ReindexService; + + constructor( + private client: SavedObjectsClient, + private credentialStore: CredentialStore, + private callWithRequest: CallClusterWithRequest, + private callWithInternalUser: CallCluster, + private readonly log: Server['log'] + ) { + if (ReindexWorker.workerSingleton) { + throw new Error(`More than one ReindexWorker cannot be created.`); + } + + this.reindexService = reindexServiceFactory( + this.callWithInternalUser, + reindexActionsFactory(this.client, this.callWithInternalUser) + ); + + ReindexWorker.workerSingleton = this; + } + + /** + * Begins loop (1) to begin checking for in progress reindex operations. + */ + public start = () => { + this.log(['debug', ...LOG_TAGS], `Starting worker...`); + this.continuePolling = true; + this.pollForOperations(); + }; + + /** + * Stops the worker from processing any further reindex operations. + */ + public stop = () => { + this.log(['debug', ...LOG_TAGS], `Stopping worker...`); + this.updateOperationLoopRunning = false; + this.continuePolling = false; + }; + + /** + * Should be called immediately after this server has started a new reindex operation. + */ + public forceRefresh = () => { + this.refresh(); + }; + + /** + * Returns whether or not the given ReindexOperation is in the worker's queue. + */ + public includes = (reindexOp: ReindexSavedObject) => { + return this.inProgressOps.map(o => o.id).includes(reindexOp.id); + }; + + /** + * Runs an async loop until all inProgress jobs are complete or failed. + */ + private startUpdateOperationLoop = async () => { + this.updateOperationLoopRunning = true; + + while (this.inProgressOps.length > 0) { + this.log(['debug', ...LOG_TAGS], `Updating ${this.inProgressOps.length} reindex operations`); + + // Push each operation through the state machine and refresh. + await Promise.all(this.inProgressOps.map(this.processNextStep)); + await this.refresh(); + } + + this.updateOperationLoopRunning = false; + }; + + private pollForOperations = async () => { + this.log(['debug', ...LOG_TAGS], `Polling for reindex operations`); + + await this.refresh(); + + if (this.continuePolling) { + setTimeout(this.pollForOperations, POLL_INTERVAL); + } + }; + + private refresh = async () => { + this.inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); + + // If there are operations in progress and we're not already updating operations, kick off the update loop + if (!this.updateOperationLoopRunning) { + this.startUpdateOperationLoop(); + } + }; + + private processNextStep = async (reindexOp: ReindexSavedObject) => { + const credential = this.credentialStore.get(reindexOp); + + if (!credential) { + // Set to paused state if the job hasn't been updated in PAUSE_WINDOW. + // This indicates that no Kibana nodes currently have credentials to update this job. + const now = moment(); + const updatedAt = moment(reindexOp.updated_at); + if (updatedAt < now.subtract(PAUSE_WINDOW)) { + return this.reindexService.pauseReindexOperation(reindexOp.attributes.indexName); + } else { + // If it has been updated recently, we assume another node has the necessary credentials, + // and this becomes a noop. + return reindexOp; + } + } + + // Setup a ReindexService specific to these credentials. + const fakeRequest = { headers: credential } as Request; + const callCluster = this.callWithRequest.bind(null, fakeRequest) as CallCluster; + const actions = reindexActionsFactory(this.client, callCluster); + const service = reindexServiceFactory(callCluster, actions); + reindexOp = await swallowExceptions(service.processNextStep, this.log)(reindexOp); + + // Update credential store with most recent state. + this.credentialStore.set(reindexOp, credential); + }; +} + +/** + * Swallows any exceptions that may occur during the reindex process. This prevents any errors from + * stopping the worker from continuing to process more jobs. + */ +const swallowExceptions = ( + func: (reindexOp: ReindexSavedObject) => Promise, + log: Server['log'] +) => async (reindexOp: ReindexSavedObject) => { + try { + return await func(reindexOp); + } catch (e) { + if (reindexOp.attributes.locked) { + log(['debug', ...LOG_TAGS], `Skipping reindexOp with unexpired lock: ${reindexOp.id}`); + } else { + log( + ['warning', ...LOG_TAGS], + `Error when trying to process reindexOp (${reindexOp.id}): ${e.toString()}` + ); + } + + return reindexOp; + } +}; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts index 2f177d6f54ccc..0aee001e89066 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts @@ -17,7 +17,7 @@ MigrationApis.getUpgradeAssistantStatus = jest.fn(); * to ensure they're wired up to the lib functions correctly. Business logic is tested * more thoroughly in the es_migration_apis test. */ -describe('reindex template API', () => { +describe('cluster checkup API', () => { const server = new Server(); server.plugins = { elasticsearch: { @@ -29,7 +29,7 @@ describe('reindex template API', () => { registerClusterCheckupRoutes(server); describe('GET /api/upgrade_assistant/reindex/{indexName}.json', () => { - it('returns a template', async () => { + it('returns state', async () => { MigrationApis.getUpgradeAssistantStatus.mockResolvedValue({ cluster: [], indices: [], diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts new file mode 100644 index 0000000000000..3e88ff5d6888b --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts @@ -0,0 +1,193 @@ +/* + * 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 { Server } from 'hapi'; + +const mockReindexService = { + detectReindexWarnings: jest.fn(), + createReindexOperation: jest.fn(), + findAllInProgressOperations: jest.fn(), + findReindexOperation: jest.fn(), + processNextStep: jest.fn(), + resumeReindexOperation: jest.fn(), +}; + +jest.mock('../lib/reindexing', () => { + return { + reindexServiceFactory: () => mockReindexService, + }; +}); + +import { ReindexSavedObject, ReindexStatus, ReindexWarning } from '../../common/types'; +import { credentialStoreFactory } from '../lib/reindexing/credential_store'; +import { registerReindexIndicesRoutes } from './reindex_indices'; + +// Need to require to get mock on named export to work. +// tslint:disable:no-var-requires +// const MigrationApis = require('../lib/es_migration_apis'); +// MigrationApis.getUpgradeAssistantStatus = jest.fn(); + +/** + * Since these route callbacks are so thin, these serve simply as integration tests + * to ensure they're wired up to the lib functions correctly. Business logic is tested + * more thoroughly in the es_migration_apis test. + */ +describe('reindex template API', () => { + const server = new Server(); + server.plugins = { + elasticsearch: { + getCluster: () => ({ callWithRequest: jest.fn() } as any), + } as any, + } as any; + server.config = () => ({ get: () => '' } as any); + server.decorate('request', 'getSavedObjectsClient', () => jest.fn()); + + const credentialStore = credentialStoreFactory(); + + const worker = { + includes: jest.fn(), + forceRefresh: jest.fn(), + } as any; + + registerReindexIndicesRoutes(server, worker, credentialStore); + + beforeEach(() => { + mockReindexService.detectReindexWarnings.mockReset(); + mockReindexService.createReindexOperation.mockReset(); + mockReindexService.findAllInProgressOperations.mockReset(); + mockReindexService.findReindexOperation.mockReset(); + mockReindexService.processNextStep.mockReset(); + mockReindexService.resumeReindexOperation.mockReset(); + worker.includes.mockReset(); + worker.forceRefresh.mockReset(); + + // Reset the credentialMap + credentialStore.clear(); + }); + + describe('GET /api/upgrade_assistant/reindex/{indexName}', () => { + it('returns the attributes of the reindex operation and reindex warnings', async () => { + mockReindexService.findReindexOperation.mockResolvedValueOnce({ + attributes: { indexName: 'wowIndex', status: ReindexStatus.inProgress }, + }); + mockReindexService.detectReindexWarnings.mockResolvedValueOnce([ReindexWarning.allField]); + + const resp = await server.inject({ + method: 'GET', + url: `/api/upgrade_assistant/reindex/wowIndex`, + }); + + // It called into the service correctly + expect(mockReindexService.findReindexOperation).toHaveBeenCalledWith('wowIndex'); + expect(mockReindexService.detectReindexWarnings).toHaveBeenCalledWith('wowIndex'); + + // It returned the right results + expect(resp.statusCode).toEqual(200); + const data = JSON.parse(resp.payload); + expect(data.reindexOp).toEqual({ indexName: 'wowIndex', status: ReindexStatus.inProgress }); + expect(data.warnings).toEqual([0]); + }); + + it("returns null for both if reindex operation doesn't exist and index doesn't exist", async () => { + mockReindexService.findReindexOperation.mockResolvedValueOnce(null); + mockReindexService.detectReindexWarnings.mockResolvedValueOnce(null); + + const resp = await server.inject({ + method: 'GET', + url: `/api/upgrade_assistant/reindex/anIndex`, + }); + + expect(resp.statusCode).toEqual(200); + const data = JSON.parse(resp.payload); + expect(data).toEqual({ warnings: null, reindexOp: null }); + }); + }); + + describe('POST /api/upgrade_assistant/reindex/{indexName}', () => { + it('creates a new reindexOp', async () => { + mockReindexService.createReindexOperation.mockResolvedValueOnce({ + attributes: { indexName: 'theIndex' }, + }); + + const resp = await server.inject({ + method: 'POST', + url: '/api/upgrade_assistant/reindex/theIndex', + }); + + // It called create correctly + expect(mockReindexService.createReindexOperation).toHaveBeenCalledWith('theIndex'); + + // It returned the right results + expect(resp.statusCode).toEqual(200); + const data = JSON.parse(resp.payload); + expect(data).toEqual({ indexName: 'theIndex' }); + }); + + it('calls worker.forceRefresh', async () => { + mockReindexService.createReindexOperation.mockResolvedValueOnce({ + attributes: { indexName: 'theIndex' }, + }); + + await server.inject({ + method: 'POST', + url: '/api/upgrade_assistant/reindex/theIndex', + }); + + expect(worker.forceRefresh).toHaveBeenCalled(); + }); + + it('inserts headers into the credentialStore', async () => { + const reindexOp = { + attributes: { indexName: 'theIndex' }, + } as ReindexSavedObject; + mockReindexService.createReindexOperation.mockResolvedValueOnce(reindexOp); + + await server.inject({ + method: 'POST', + url: '/api/upgrade_assistant/reindex/theIndex', + headers: { + 'kbn-auth-x': 'HERE!', + }, + }); + + expect(credentialStore.get(reindexOp)!['kbn-auth-x']).toEqual('HERE!'); + }); + + it('resumes a reindexOp if it is paused', async () => { + mockReindexService.findReindexOperation.mockResolvedValueOnce({ + attributes: { indexName: 'theIndex', status: ReindexStatus.paused }, + }); + mockReindexService.resumeReindexOperation.mockResolvedValueOnce({ + attributes: { indexName: 'theIndex', status: ReindexStatus.inProgress }, + }); + + const resp = await server.inject({ + method: 'POST', + url: '/api/upgrade_assistant/reindex/theIndex', + }); + + // It called resume correctly + expect(mockReindexService.resumeReindexOperation).toHaveBeenCalledWith('theIndex'); + expect(mockReindexService.createReindexOperation).not.toHaveBeenCalled(); + + // It returned the right results + expect(resp.statusCode).toEqual(200); + const data = JSON.parse(resp.payload); + expect(data).toEqual({ indexName: 'theIndex', status: ReindexStatus.inProgress }); + }); + }); + + describe('DELETE /api/upgrade_assistant/reindex/{indexName}', () => { + it('returns a 501', async () => { + const resp = await server.inject({ + method: 'DELETE', + url: '/api/upgrade_assistant/reindex/cancelMe', + }); + + expect(resp.statusCode).toEqual(501); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts new file mode 100644 index 0000000000000..5acc75f8a1e0c --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts @@ -0,0 +1,136 @@ +/* + * 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 Boom from 'boom'; +import { Server } from 'hapi'; + +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { SavedObjectsClient } from 'src/server/saved_objects'; +import { ReindexStatus } from '../../common/types'; +import { reindexServiceFactory, ReindexWorker } from '../lib/reindexing'; +import { CredentialStore } from '../lib/reindexing/credential_store'; +import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; + +export function registerReindexWorker(server: Server, credentialStore: CredentialStore) { + const { callWithRequest, callWithInternalUser } = server.plugins.elasticsearch.getCluster( + 'admin' + ); + const savedObjectsRepository = server.savedObjects.getSavedObjectsRepository( + callWithInternalUser + ); + const savedObjectsClient = new server.savedObjects.SavedObjectsClient( + savedObjectsRepository + ) as SavedObjectsClient; + + // Cannot pass server.log directly because it's value changes during startup (?). + // Use this function to proxy through. + const log: Server['log'] = ( + tags: string | string[], + data?: string | object | (() => any), + timestamp?: number + ) => server.log(tags, data, timestamp); + + const worker = new ReindexWorker( + savedObjectsClient, + credentialStore, + callWithRequest, + callWithInternalUser, + log + ); + + // Wait for ES connection before starting the polling loop. + server.plugins.elasticsearch.waitUntilReady().then(() => { + worker.start(); + server.events.on('stop', () => worker.stop()); + }); + + return worker; +} + +export function registerReindexIndicesRoutes( + server: Server, + worker: ReindexWorker, + credentialStore: CredentialStore +) { + const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); + const BASE_PATH = '/api/upgrade_assistant/reindex'; + + // Start reindex for an index + server.route({ + path: `${BASE_PATH}/{indexName}`, + method: 'POST', + async handler(request) { + const client = request.getSavedObjectsClient(); + const { indexName } = request.params; + + const callCluster = callWithRequest.bind(null, request) as CallCluster; + const reindexActions = reindexActionsFactory(client, callCluster); + const reindexService = reindexServiceFactory(callCluster, reindexActions); + + try { + const existingOp = await reindexService.findReindexOperation(indexName); + + // If the reindexOp already exists and it's paused, resume it. Otherwise create a new one. + const reindexOp = + existingOp && existingOp.attributes.status === ReindexStatus.paused + ? await reindexService.resumeReindexOperation(indexName) + : await reindexService.createReindexOperation(indexName); + + // Add users credentials for the worker to use + credentialStore.set(reindexOp, request.headers); + + // Kick the worker on this node to immediately pickup the new reindex operation. + worker.forceRefresh(); + + return reindexOp.attributes; + } catch (e) { + if (!e.isBoom) { + return Boom.boomify(e, { statusCode: 500 }); + } + + return e; + } + }, + }); + + // Get status + server.route({ + path: `${BASE_PATH}/{indexName}`, + method: 'GET', + async handler(request) { + const client = request.getSavedObjectsClient(); + const { indexName } = request.params; + const callCluster = callWithRequest.bind(null, request) as CallCluster; + const reindexActions = reindexActionsFactory(client, callCluster); + const reindexService = reindexServiceFactory(callCluster, reindexActions); + + try { + const reindexOp = await reindexService.findReindexOperation(indexName); + const reindexWarnings = await reindexService.detectReindexWarnings(indexName); + + return { + warnings: reindexWarnings, + reindexOp: reindexOp ? reindexOp.attributes : null, + }; + } catch (e) { + if (!e.isBoom) { + return Boom.boomify(e, { statusCode: 500 }); + } + + return e; + } + }, + }); + + // Cancel reindex + server.route({ + path: `${BASE_PATH}/{indexName}`, + method: 'DELETE', + async handler(request) { + return Boom.notImplemented(); + }, + }); +} diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 67d13381b3a77..25c9e4808ebad 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -18,4 +18,5 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/saved_object_api_integration/security_and_spaces/config'), require.resolve('../test/saved_object_api_integration/security_only/config'), require.resolve('../test/saved_object_api_integration/spaces_only/config'), + require.resolve('../test/upgrade_assistant_integration/config'), ]); diff --git a/x-pack/test/functional/es_archives/upgrade_assistant/reindex/data.json b/x-pack/test/functional/es_archives/upgrade_assistant/reindex/data.json new file mode 100644 index 0000000000000..05aa111147451 --- /dev/null +++ b/x-pack/test/functional/es_archives/upgrade_assistant/reindex/data.json @@ -0,0 +1,77 @@ +{ + "type": "doc", + "value": { + "index": "dummydata", + "type": "_doc", + "source": { + "@timestamp": "2018-10-30T18:51:56.792Z", + "host": { + "name": "foo.home", + "architecture": "x86_64", + "os": { + "version": "10.14", + "family": "darwin", + "build": "18A391", + "platform": "darwin" + } + }, + "versions": [ + "1.0.0", + "8.8.4" + ], + "https": true, + "response_ms": 114 + } + } +} + +{ + "type": "doc", + "value": { + "index": "dummydata", + "type": "_doc", + "source": { + "@timestamp": "2018-12-30T18:51:56.792Z", + "host": { + "name": "bar.home", + "architecture": "x86_64", + "os": { + "version": "10.12", + "family": "darwin", + "build": "18AXX", + "platform": "darwin" + } + }, + "versions": [ + "0.4" + ], + "https": false, + "response_ms": 1567 + } + } +} + +{ + "type": "doc", + "value": { + "index": "dummydata", + "type": "_doc", + "source": { + "@timestamp": "2018-01-30T18:51:56.792Z", + "host": { + "name": "qux.home", + "architecture": "x86_64", + "os": { + "version": "3.24", + "family": "linux", + "build": "YYY", + "platform": "linux" + } + }, + "versions": [ + ], + "https": true, + "response_ms": 94 + } + } +} diff --git a/x-pack/test/upgrade_assistant_integration/config.js b/x-pack/test/upgrade_assistant_integration/config.js new file mode 100644 index 0000000000000..2992600be2db1 --- /dev/null +++ b/x-pack/test/upgrade_assistant_integration/config.js @@ -0,0 +1,46 @@ +/* + * 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 path from 'path'; +import { + EsProvider, +} from './services'; + +// Temporary until https://github.com/elastic/kibana/pull/29184 is merged +delete process.env.TEST_ES_SNAPSHOT_VERSION; + +export default async function ({ readConfigFile }) { + + // Read the Kibana API integration tests config file so that we can utilize its services. + const kibanaAPITestsConfig = await readConfigFile(require.resolve('../../../test/api_integration/config.js')); + const xPackFunctionalTestsConfig = await readConfigFile(require.resolve('../functional/config.js')); + const kibanaCommonConfig = await readConfigFile(require.resolve('../../../test/common/config.js')); + + return { + testFiles: [require.resolve('./upgrade_assistant')], + servers: xPackFunctionalTestsConfig.get('servers'), + services: { + supertest: kibanaAPITestsConfig.get('services.supertest'), + es: EsProvider, + esArchiver: kibanaCommonConfig.get('services.esArchiver'), + }, + esArchiver: xPackFunctionalTestsConfig.get('esArchiver'), + junit: { + reportName: 'X-Pack Upgrade Assistant Integration Tests', + }, + kbnTestServer: { + ...xPackFunctionalTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), + '--optimize.enabled=false', + ], + }, + esTestCluster: { + ...xPackFunctionalTestsConfig.get('esTestCluster'), + dataArchive: path.resolve(__dirname, './fixtures/data_archives/upgrade_assistant.zip'), + } + }; +} diff --git a/x-pack/test/upgrade_assistant_integration/fixtures/data_archives/upgrade_assistant.zip b/x-pack/test/upgrade_assistant_integration/fixtures/data_archives/upgrade_assistant.zip new file mode 100644 index 0000000000000..d5dd87ac6184d Binary files /dev/null and b/x-pack/test/upgrade_assistant_integration/fixtures/data_archives/upgrade_assistant.zip differ diff --git a/x-pack/test/upgrade_assistant_integration/services/es.js b/x-pack/test/upgrade_assistant_integration/services/es.js new file mode 100644 index 0000000000000..d8464794bd864 --- /dev/null +++ b/x-pack/test/upgrade_assistant_integration/services/es.js @@ -0,0 +1,18 @@ +/* + * 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 { format as formatUrl } from 'url'; + +import elasticsearch from 'elasticsearch'; + +export function EsProvider({ getService }) { + const config = getService('config'); + + return new elasticsearch.Client({ + host: formatUrl(config.get('servers.elasticsearch')), + requestTimeout: config.get('timeouts.esRequestTimeout'), + }); +} diff --git a/x-pack/test/upgrade_assistant_integration/services/index.js b/x-pack/test/upgrade_assistant_integration/services/index.js new file mode 100644 index 0000000000000..27007c4ccf845 --- /dev/null +++ b/x-pack/test/upgrade_assistant_integration/services/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { EsProvider } from './es'; diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js new file mode 100644 index 0000000000000..a81cf14198b4a --- /dev/null +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export default function ({ loadTestFile }) { + describe('upgrade assistant', function () { + this.tags('ciGroup5'); + + loadTestFile(require.resolve('./reindexing')); + }); +} diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js new file mode 100644 index 0000000000000..5f7902b02e43a --- /dev/null +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js @@ -0,0 +1,150 @@ +/* + * 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 'expect.js'; + +import { ReindexStatus, REINDEX_OP_TYPE } from '../../../plugins/upgrade_assistant/common/types'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + // Utility function that keeps polling API until reindex operation has completed or failed. + const waitForReindexToComplete = async (indexName) => { + console.log(`Waiting for reindex to complete...`); + let lastState; + + while (true) { + lastState = (await supertest.get(`/api/upgrade_assistant/reindex/${indexName}`).expect(200)).body.reindexOp; + // Once the operation is completed or failed and unlocked, stop polling. + if (lastState.status !== ReindexStatus.inProgress && lastState.locked === null) { + break; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + return lastState; + }; + + describe('reindexing', () => { + afterEach(() => { + // Cleanup saved objects + return es.deleteByQuery({ + index: '.kibana', + refresh: true, + body: { + query: { + "simple_query_string": { + query: REINDEX_OP_TYPE, + fields: ["type"] + } + } + } + }); + }); + + it('should create a new index with the same documents', async () => { + await esArchiver.load('upgrade_assistant/reindex'); + const { body } = await supertest + .post(`/api/upgrade_assistant/reindex/dummydata`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(body.indexName).to.equal('dummydata'); + expect(body.status).to.equal(ReindexStatus.inProgress); + + const lastState = await waitForReindexToComplete('dummydata'); + expect(lastState.errorMessage).to.equal(null); + expect(lastState.status).to.equal(ReindexStatus.completed); + + const { newIndexName } = lastState; + const indexSummary = await es.indices.get({ index: 'dummydata' }); + + // The new index was created + expect(indexSummary[newIndexName]).to.be.an('object'); + // The original index name is aliased to the new one + expect(indexSummary[newIndexName].aliases.dummydata).to.be.an('object'); + // The number of documents in the new index matches what we expect + expect( + (await es.count({ index: lastState.newIndexName })).count + ).to.be(3); + + // Cleanup newly created index + await es.indices.delete({ + index: lastState.newIndexName + }); + }); + + it('should update any aliases', async () => { + await esArchiver.load('upgrade_assistant/reindex'); + + // Add aliases and ensure each returns the right number of docs + await es.indices.updateAliases({ + body: { + actions: [ + { add: { index: 'dummydata', alias: 'myAlias' } }, + { add: { index: 'dummy*', alias: 'wildcardAlias' } }, + { add: { index: 'dummydata', alias: 'myHttpsAlias', filter: { term: { https: true } } } } + ] + } + }); + expect( + (await es.count({ index: 'myAlias' })).count + ).to.be(3); + expect( + (await es.count({ index: 'wildcardAlias' })).count + ).to.be(3); + expect( + (await es.count({ index: 'myHttpsAlias' })).count + ).to.be(2); + + // Reindex + await supertest + .post(`/api/upgrade_assistant/reindex/dummydata`) + .set('kbn-xsrf', 'xxx') + .expect(200); + const lastState = await waitForReindexToComplete('dummydata'); + + // The regular aliases should still return 3 docs + expect( + (await es.count({ index: 'myAlias' })).count + ).to.be(3); + expect( + (await es.count({ index: 'wildcardAlias' })).count + ).to.be(3); + // The filtered alias should still return 2 docs + expect( + (await es.count({ index: 'myHttpsAlias' })).count + ).to.be(2); + + // Cleanup newly created index + await es.indices.delete({ + index: lastState.newIndexName + }); + }); + + it('shows no warnings', async () => { + const resp = await supertest.get(`/api/upgrade_assistant/reindex/6.0-data`); + expect(resp.body.warnings.length).to.be(0); + }); + + it('reindexes old 6.0 index', async () => { + const { body } = await supertest + .post(`/api/upgrade_assistant/reindex/6.0-data`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(body.indexName).to.equal('6.0-data'); + expect(body.status).to.equal(ReindexStatus.inProgress); + + const lastState = await waitForReindexToComplete('6.0-data'); + expect(lastState.errorMessage).to.equal(null); + expect(lastState.status).to.equal(ReindexStatus.completed); + }); + }); +}