[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:
Pierre Gayvallet 2023-01-30 17:05:53 +01:00 committed by GitHub
parent 3a961fb132
commit e70fceaf9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 230 additions and 110 deletions

View file

@ -14,9 +14,19 @@ export interface SavedObject {
[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'))
.split(/\r?\n\r?\n/)
.filter((line) => !!line)
.map((line) => JSON.parse(line));
.map((line) => JSON.parse(line))
.filter(
stripSummary
? (object) => {
return object.type && object.id;
}
: () => true
);
}

View file

@ -25,6 +25,7 @@ interface ImportApiResponse {
success: boolean;
[key: string]: unknown;
}
export class KbnClientImportExport {
constructor(
public readonly log: ToolingLog,
@ -92,7 +93,7 @@ export class KbnClientImportExport {
const src = this.resolveAndValidatePath(path);
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 });
const { deleted, missing } = await this.savedObjects.bulkDelete({

View file

@ -6,12 +6,9 @@
* Side Public License, v 1.
*/
import { inspect } from 'util';
import * as Rx from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { isAxiosResponseError } from '@kbn/dev-utils';
import { createFailError } from '@kbn/dev-cli-errors';
import { ToolingLog } from '@kbn/tooling-log';
import { chunk } from 'lodash';
import type { ToolingLog } from '@kbn/tooling-log';
import type { SavedObjectsBulkDeleteResponse } from '@kbn/core-saved-objects-api-server';
import { KbnClientRequester, uriencode } from './kbn_client_requester';
@ -57,22 +54,15 @@ interface MigrateResponse {
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 {
space?: string;
types: string[];
}
interface CleanApiResponse {
deleted: number;
}
interface DeleteObjectsOptions {
space?: string;
objects: Array<{
@ -81,13 +71,43 @@ interface DeleteObjectsOptions {
}>;
}
async function concurrently<T>(maxConcurrency: number, arr: T[], fn: (item: T) => Promise<void>) {
if (arr.length) {
await Rx.lastValueFrom(
Rx.from(arr).pipe(mergeMap(async (item) => await fn(item), maxConcurrency))
);
}
}
const DELETE_CHUNK_SIZE = 50;
// add types here
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.
@ -194,74 +214,22 @@ export class KbnClientSavedObjects {
public async clean(options: CleanOptions) {
this.log.debug('Cleaning all saved objects', { space: options.space });
let deleted = 0;
while (true) {
const resp = await this.requester.request<FindApiResponse>({
method: 'GET',
path: options.space
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_find`
: `/internal/ftr/kbn_client_so/_find`,
query: {
per_page: 1000,
type: options.types,
fields: 'none',
},
});
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;
}
}
const resp = await this.requester.request<CleanApiResponse>({
method: 'POST',
path: options.space
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_clean`
: `/internal/ftr/kbn_client_so/_clean`,
body: {
types: options.types,
},
});
const deleted = resp.data.deleted;
this.log.success('deleted', deleted, 'objects');
}
public async cleanStandardList(options?: { space?: string }) {
// add types here
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 };
const newOptions = { types: STANDARD_LIST_TYPES, space: options?.space };
await this.clean(newOptions);
}
@ -269,28 +237,25 @@ export class KbnClientSavedObjects {
let deleted = 0;
let missing = 0;
await concurrently(20, options.objects, async (obj) => {
try {
await this.requester.request({
method: 'DELETE',
path: options.space
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/${obj.type}/${obj.id}`
: uriencode`/internal/ftr/kbn_client_so/${obj.type}/${obj.id}`,
});
deleted++;
} catch (error) {
if (isAxiosResponseError(error)) {
if (error.response.status === 404) {
missing++;
return;
}
const chunks = chunk(options.objects, DELETE_CHUNK_SIZE);
throw createFailError(`${error.response.status} resp: ${inspect(error.response.data)}`);
for (let i = 0; i < chunks.length; i++) {
const objects = chunks[i];
const { data: response } = await this.requester.request<SavedObjectsBulkDeleteResponse>({
method: 'POST',
path: options.space
? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_bulk_delete`
: uriencode`/internal/ftr/kbn_client_so/_bulk_delete`,
body: objects.map(({ type, id }) => ({ type, id })),
});
response.statuses.forEach((status) => {
if (status.success) {
deleted++;
} else if (status.error?.statusCode === 404) {
missing++;
}
throw error;
}
});
});
}
return { deleted, missing };
}

View file

@ -31,5 +31,6 @@
"@kbn/stdio-dev-helpers",
"@kbn/babel-register",
"@kbn/repo-packages",
"@kbn/core-saved-objects-api-server",
]
}

View 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,
},
});
})
);
};

View file

@ -13,6 +13,7 @@ import { registerDeleteRoute } from './delete';
import { registerFindRoute } from './find';
import { registerGetRoute } from './get';
import { registerUpdateRoute } from './update';
import { registerCleanRoute } from './clean';
export const registerKbnClientSoRoutes = (router: IRouter) => {
registerBulkDeleteRoute(router);
@ -21,4 +22,5 @@ export const registerKbnClientSoRoutes = (router: IRouter) => {
registerFindRoute(router);
registerGetRoute(router);
registerUpdateRoute(router);
registerCleanRoute(router);
};

View 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();
});
}

View file

@ -21,5 +21,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./bulk_delete'));
loadTestFile(require.resolve('./clean'));
});
}