mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
[FTR] KbnClientSavedObjects
improvements (#149582)
## Summary Follow-up of https://github.com/elastic/kibana/pull/149188 - Use the bulkDelete API for `KbnClientSavedObjects.bulkDelete` - Create a dedicated `/_clean` endpoint for `KbnClientSavedObjects.clean` and `KbnClientSavedObjects.cleanStandardList` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
3a961fb132
commit
e70fceaf9d
8 changed files with 230 additions and 110 deletions
|
@ -14,9 +14,19 @@ export interface SavedObject {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseArchive(path: string): Promise<SavedObject[]> {
|
export async function parseArchive(
|
||||||
|
path: string,
|
||||||
|
{ stripSummary = false }: { stripSummary?: boolean } = {}
|
||||||
|
): Promise<SavedObject[]> {
|
||||||
return (await Fs.readFile(path, 'utf-8'))
|
return (await Fs.readFile(path, 'utf-8'))
|
||||||
.split(/\r?\n\r?\n/)
|
.split(/\r?\n\r?\n/)
|
||||||
.filter((line) => !!line)
|
.filter((line) => !!line)
|
||||||
.map((line) => JSON.parse(line));
|
.map((line) => JSON.parse(line))
|
||||||
|
.filter(
|
||||||
|
stripSummary
|
||||||
|
? (object) => {
|
||||||
|
return object.type && object.id;
|
||||||
|
}
|
||||||
|
: () => true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ interface ImportApiResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KbnClientImportExport {
|
export class KbnClientImportExport {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly log: ToolingLog,
|
public readonly log: ToolingLog,
|
||||||
|
@ -92,7 +93,7 @@ export class KbnClientImportExport {
|
||||||
const src = this.resolveAndValidatePath(path);
|
const src = this.resolveAndValidatePath(path);
|
||||||
this.log.debug('unloading docs from archive at', src);
|
this.log.debug('unloading docs from archive at', src);
|
||||||
|
|
||||||
const objects = await parseArchive(src);
|
const objects = await parseArchive(src, { stripSummary: true });
|
||||||
this.log.info('deleting', objects.length, 'objects', { space: options?.space });
|
this.log.info('deleting', objects.length, 'objects', { space: options?.space });
|
||||||
|
|
||||||
const { deleted, missing } = await this.savedObjects.bulkDelete({
|
const { deleted, missing } = await this.savedObjects.bulkDelete({
|
||||||
|
|
|
@ -6,12 +6,9 @@
|
||||||
* Side Public License, v 1.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { inspect } from 'util';
|
import { chunk } from 'lodash';
|
||||||
import * as Rx from 'rxjs';
|
import type { ToolingLog } from '@kbn/tooling-log';
|
||||||
import { mergeMap } from 'rxjs/operators';
|
import type { SavedObjectsBulkDeleteResponse } from '@kbn/core-saved-objects-api-server';
|
||||||
import { isAxiosResponseError } from '@kbn/dev-utils';
|
|
||||||
import { createFailError } from '@kbn/dev-cli-errors';
|
|
||||||
import { ToolingLog } from '@kbn/tooling-log';
|
|
||||||
|
|
||||||
import { KbnClientRequester, uriencode } from './kbn_client_requester';
|
import { KbnClientRequester, uriencode } from './kbn_client_requester';
|
||||||
|
|
||||||
|
@ -57,22 +54,15 @@ interface MigrateResponse {
|
||||||
result: Array<{ status: string }>;
|
result: Array<{ status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FindApiResponse {
|
|
||||||
saved_objects: Array<{
|
|
||||||
type: string;
|
|
||||||
id: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
total: number;
|
|
||||||
per_page: number;
|
|
||||||
page: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CleanOptions {
|
interface CleanOptions {
|
||||||
space?: string;
|
space?: string;
|
||||||
types: string[];
|
types: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CleanApiResponse {
|
||||||
|
deleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface DeleteObjectsOptions {
|
interface DeleteObjectsOptions {
|
||||||
space?: string;
|
space?: string;
|
||||||
objects: Array<{
|
objects: Array<{
|
||||||
|
@ -81,13 +71,43 @@ interface DeleteObjectsOptions {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function concurrently<T>(maxConcurrency: number, arr: T[], fn: (item: T) => Promise<void>) {
|
const DELETE_CHUNK_SIZE = 50;
|
||||||
if (arr.length) {
|
|
||||||
await Rx.lastValueFrom(
|
// add types here
|
||||||
Rx.from(arr).pipe(mergeMap(async (item) => await fn(item), maxConcurrency))
|
const STANDARD_LIST_TYPES = [
|
||||||
);
|
'url',
|
||||||
}
|
'index-pattern',
|
||||||
}
|
'action',
|
||||||
|
'query',
|
||||||
|
'alert',
|
||||||
|
'graph-workspace',
|
||||||
|
'tag',
|
||||||
|
'visualization',
|
||||||
|
'canvas-element',
|
||||||
|
'canvas-workpad',
|
||||||
|
'dashboard',
|
||||||
|
'search',
|
||||||
|
'lens',
|
||||||
|
'map',
|
||||||
|
'cases',
|
||||||
|
'uptime-dynamic-settings',
|
||||||
|
'osquery-saved-query',
|
||||||
|
'osquery-pack',
|
||||||
|
'infrastructure-ui-source',
|
||||||
|
'metrics-explorer-view',
|
||||||
|
'inventory-view',
|
||||||
|
'infrastructure-monitoring-log-view',
|
||||||
|
'apm-indices',
|
||||||
|
// Fleet saved object types
|
||||||
|
'ingest-outputs',
|
||||||
|
'ingest-download-sources',
|
||||||
|
'ingest-agent-policies',
|
||||||
|
'ingest-package-policies',
|
||||||
|
'epm-packages',
|
||||||
|
'epm-packages-assets',
|
||||||
|
'fleet-preconfiguration-deletion-record',
|
||||||
|
'fleet-fleet-server-host',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SO client for FTR.
|
* SO client for FTR.
|
||||||
|
@ -194,74 +214,22 @@ export class KbnClientSavedObjects {
|
||||||
public async clean(options: CleanOptions) {
|
public async clean(options: CleanOptions) {
|
||||||
this.log.debug('Cleaning all saved objects', { space: options.space });
|
this.log.debug('Cleaning all saved objects', { space: options.space });
|
||||||
|
|
||||||
let deleted = 0;
|
const resp = await this.requester.request<CleanApiResponse>({
|
||||||
|
method: 'POST',
|
||||||
while (true) {
|
|
||||||
const resp = await this.requester.request<FindApiResponse>({
|
|
||||||
method: 'GET',
|
|
||||||
path: options.space
|
path: options.space
|
||||||
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_find`
|
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_clean`
|
||||||
: `/internal/ftr/kbn_client_so/_find`,
|
: `/internal/ftr/kbn_client_so/_clean`,
|
||||||
query: {
|
body: {
|
||||||
per_page: 1000,
|
types: options.types,
|
||||||
type: options.types,
|
|
||||||
fields: 'none',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const deleted = resp.data.deleted;
|
||||||
this.log.info('deleting batch of', resp.data.saved_objects.length, 'objects');
|
|
||||||
const deletion = await this.bulkDelete({
|
|
||||||
space: options.space,
|
|
||||||
objects: resp.data.saved_objects,
|
|
||||||
});
|
|
||||||
deleted += deletion.deleted;
|
|
||||||
|
|
||||||
if (resp.data.total <= resp.data.per_page) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log.success('deleted', deleted, 'objects');
|
this.log.success('deleted', deleted, 'objects');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async cleanStandardList(options?: { space?: string }) {
|
public async cleanStandardList(options?: { space?: string }) {
|
||||||
// add types here
|
const newOptions = { types: STANDARD_LIST_TYPES, space: options?.space };
|
||||||
const types = [
|
|
||||||
'url',
|
|
||||||
'index-pattern',
|
|
||||||
'action',
|
|
||||||
'query',
|
|
||||||
'alert',
|
|
||||||
'graph-workspace',
|
|
||||||
'tag',
|
|
||||||
'visualization',
|
|
||||||
'canvas-element',
|
|
||||||
'canvas-workpad',
|
|
||||||
'dashboard',
|
|
||||||
'search',
|
|
||||||
'lens',
|
|
||||||
'map',
|
|
||||||
'cases',
|
|
||||||
'uptime-dynamic-settings',
|
|
||||||
'osquery-saved-query',
|
|
||||||
'osquery-pack',
|
|
||||||
'infrastructure-ui-source',
|
|
||||||
'metrics-explorer-view',
|
|
||||||
'inventory-view',
|
|
||||||
'infrastructure-monitoring-log-view',
|
|
||||||
'apm-indices',
|
|
||||||
// Fleet saved object types
|
|
||||||
'ingest-outputs',
|
|
||||||
'ingest-download-sources',
|
|
||||||
'ingest-agent-policies',
|
|
||||||
'ingest-package-policies',
|
|
||||||
'epm-packages',
|
|
||||||
'epm-packages-assets',
|
|
||||||
'fleet-preconfiguration-deletion-record',
|
|
||||||
'fleet-fleet-server-host',
|
|
||||||
];
|
|
||||||
|
|
||||||
const newOptions = { types, space: options?.space };
|
|
||||||
await this.clean(newOptions);
|
await this.clean(newOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -269,28 +237,25 @@ export class KbnClientSavedObjects {
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
let missing = 0;
|
let missing = 0;
|
||||||
|
|
||||||
await concurrently(20, options.objects, async (obj) => {
|
const chunks = chunk(options.objects, DELETE_CHUNK_SIZE);
|
||||||
try {
|
|
||||||
await this.requester.request({
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
method: 'DELETE',
|
const objects = chunks[i];
|
||||||
|
const { data: response } = await this.requester.request<SavedObjectsBulkDeleteResponse>({
|
||||||
|
method: 'POST',
|
||||||
path: options.space
|
path: options.space
|
||||||
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/${obj.type}/${obj.id}`
|
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_bulk_delete`
|
||||||
: uriencode`/internal/ftr/kbn_client_so/${obj.type}/${obj.id}`,
|
: uriencode`/internal/ftr/kbn_client_so/_bulk_delete`,
|
||||||
|
body: objects.map(({ type, id }) => ({ type, id })),
|
||||||
});
|
});
|
||||||
|
response.statuses.forEach((status) => {
|
||||||
|
if (status.success) {
|
||||||
deleted++;
|
deleted++;
|
||||||
} catch (error) {
|
} else if (status.error?.statusCode === 404) {
|
||||||
if (isAxiosResponseError(error)) {
|
|
||||||
if (error.response.status === 404) {
|
|
||||||
missing++;
|
missing++;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createFailError(`${error.response.status} resp: ${inspect(error.response.data)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { deleted, missing };
|
return { deleted, missing };
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,5 +31,6 @@
|
||||||
"@kbn/stdio-dev-helpers",
|
"@kbn/stdio-dev-helpers",
|
||||||
"@kbn/babel-register",
|
"@kbn/babel-register",
|
||||||
"@kbn/repo-packages",
|
"@kbn/repo-packages",
|
||||||
|
"@kbn/core-saved-objects-api-server",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
48
src/plugins/ftr_apis/server/routes/kbn_client_so/clean.ts
Normal file
48
src/plugins/ftr_apis/server/routes/kbn_client_so/clean.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IRouter } from '@kbn/core/server';
|
||||||
|
import { schema } from '@kbn/config-schema';
|
||||||
|
import { KBN_CLIENT_API_PREFIX, listHiddenTypes, catchAndReturnBoomErrors } from './utils';
|
||||||
|
|
||||||
|
export const registerCleanRoute = (router: IRouter) => {
|
||||||
|
router.post(
|
||||||
|
{
|
||||||
|
path: `${KBN_CLIENT_API_PREFIX}/_clean`,
|
||||||
|
options: {
|
||||||
|
tags: ['access:ftrApis'],
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
body: schema.object({
|
||||||
|
types: schema.arrayOf(schema.string()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
catchAndReturnBoomErrors(async (ctx, req, res) => {
|
||||||
|
const { types } = req.body;
|
||||||
|
const { savedObjects } = await ctx.core;
|
||||||
|
const hiddenTypes = listHiddenTypes(savedObjects.typeRegistry);
|
||||||
|
const soClient = savedObjects.getClient({ includedHiddenTypes: hiddenTypes });
|
||||||
|
|
||||||
|
const finder = soClient.createPointInTimeFinder({ type: types, perPage: 100 });
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
for await (const response of finder.find()) {
|
||||||
|
const objects = response.saved_objects.map(({ type, id }) => ({ type, id }));
|
||||||
|
const { statuses } = await soClient.bulkDelete(objects, { force: true });
|
||||||
|
deleted += statuses.filter((status) => status.success).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.ok({
|
||||||
|
body: {
|
||||||
|
deleted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
|
@ -13,6 +13,7 @@ import { registerDeleteRoute } from './delete';
|
||||||
import { registerFindRoute } from './find';
|
import { registerFindRoute } from './find';
|
||||||
import { registerGetRoute } from './get';
|
import { registerGetRoute } from './get';
|
||||||
import { registerUpdateRoute } from './update';
|
import { registerUpdateRoute } from './update';
|
||||||
|
import { registerCleanRoute } from './clean';
|
||||||
|
|
||||||
export const registerKbnClientSoRoutes = (router: IRouter) => {
|
export const registerKbnClientSoRoutes = (router: IRouter) => {
|
||||||
registerBulkDeleteRoute(router);
|
registerBulkDeleteRoute(router);
|
||||||
|
@ -21,4 +22,5 @@ export const registerKbnClientSoRoutes = (router: IRouter) => {
|
||||||
registerFindRoute(router);
|
registerFindRoute(router);
|
||||||
registerGetRoute(router);
|
registerGetRoute(router);
|
||||||
registerUpdateRoute(router);
|
registerUpdateRoute(router);
|
||||||
|
registerCleanRoute(router);
|
||||||
};
|
};
|
||||||
|
|
92
x-pack/test/ftr_apis/security_and_spaces/apis/clean.ts
Normal file
92
x-pack/test/ftr_apis/security_and_spaces/apis/clean.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from '@kbn/expect';
|
||||||
|
import { USERS, User, ExpectedResponse } from '../../common/lib';
|
||||||
|
import { FtrProviderContext } from '../services';
|
||||||
|
import { createData, createTestSpaces, deleteData, deleteTestSpaces } from './test_utils';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default function (ftrContext: FtrProviderContext) {
|
||||||
|
const supertest = ftrContext.getService('supertestWithoutAuth');
|
||||||
|
|
||||||
|
describe('POST /internal/ftr/kbn_client_so/_clean', () => {
|
||||||
|
before(async () => {
|
||||||
|
await createTestSpaces(ftrContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await deleteTestSpaces(ftrContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await createData(ftrContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await deleteData(ftrContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
const responses: Record<string, ExpectedResponse> = {
|
||||||
|
authorized: {
|
||||||
|
httpCode: 200,
|
||||||
|
expectResponse: ({ body }) => {
|
||||||
|
expect(body.deleted).to.be.greaterThan(0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unauthorized: {
|
||||||
|
httpCode: 403,
|
||||||
|
expectResponse: ({ body }) => {
|
||||||
|
expect(body).to.eql({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Forbidden',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expectedResults: Record<string, User[]> = {
|
||||||
|
authorized: [USERS.SUPERUSER],
|
||||||
|
unauthorized: [
|
||||||
|
USERS.DEFAULT_SPACE_READ_USER,
|
||||||
|
USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER,
|
||||||
|
USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER,
|
||||||
|
USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER,
|
||||||
|
USERS.DEFAULT_SPACE_DASHBOARD_READ_USER,
|
||||||
|
USERS.DEFAULT_SPACE_VISUALIZE_READ_USER,
|
||||||
|
USERS.DEFAULT_SPACE_MAPS_READ_USER,
|
||||||
|
USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER,
|
||||||
|
USERS.NOT_A_KIBANA_USER,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUserTest = (
|
||||||
|
{ username, password, description }: User,
|
||||||
|
{ httpCode, expectResponse }: ExpectedResponse
|
||||||
|
) => {
|
||||||
|
it(`returns expected ${httpCode} response for ${description ?? username}`, async () => {
|
||||||
|
await supertest
|
||||||
|
.post(`/internal/ftr/kbn_client_so/_clean`)
|
||||||
|
.send({ types: ['tag', 'dashboard', 'visualization'] })
|
||||||
|
.auth(username, password)
|
||||||
|
.expect(httpCode)
|
||||||
|
.then(expectResponse);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestSuite = () => {
|
||||||
|
Object.entries(expectedResults).forEach(([responseId, users]) => {
|
||||||
|
const response: ExpectedResponse = responses[responseId];
|
||||||
|
users.forEach((user) => {
|
||||||
|
createUserTest(user, response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
createTestSuite();
|
||||||
|
});
|
||||||
|
}
|
|
@ -21,5 +21,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
|
||||||
loadTestFile(require.resolve('./delete'));
|
loadTestFile(require.resolve('./delete'));
|
||||||
loadTestFile(require.resolve('./find'));
|
loadTestFile(require.resolve('./find'));
|
||||||
loadTestFile(require.resolve('./bulk_delete'));
|
loadTestFile(require.resolve('./bulk_delete'));
|
||||||
|
loadTestFile(require.resolve('./clean'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue