Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add SavedObject export hooks #87807

Merged
merged 22 commits into from
Jan 21, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/core/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ export {
SavedObjectsExportByObjectOptions,
SavedObjectsExportByTypeOptions,
SavedObjectsExportError,
SavedObjectsTypeExportHook,
SavedObjectsExportContext,
SavedObjectsImporter,
ISavedObjectsImporter,
SavedObjectsImportError,
Expand Down
1 change: 1 addition & 0 deletions src/core/server/legacy/legacy_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export class LegacyService implements CoreService {
setClientFactoryProvider: setupDeps.core.savedObjects.setClientFactoryProvider,
addClientWrapper: setupDeps.core.savedObjects.addClientWrapper,
registerType: setupDeps.core.savedObjects.registerType,
registerExportHook: setupDeps.core.savedObjects.registerExportHook,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: instead of having a dedicated registerExportHook, should we make it part of the existing SavedObjectsTypeManagementDefinition interface since this already has importableAndExportable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, we came with the same conclusion with @rudolf in #84980 (comment). I will adapt that, as I did in #87996

},
status: {
isStatusPageAnonymous: setupDeps.core.status.isStatusPageAnonymous,
Expand Down
1 change: 1 addition & 0 deletions src/core/server/plugins/plugin_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
setClientFactoryProvider: deps.savedObjects.setClientFactoryProvider,
addClientWrapper: deps.savedObjects.addClientWrapper,
registerType: deps.savedObjects.registerType,
registerExportHook: deps.savedObjects.registerExportHook,
},
status: {
core$: deps.status.core$,
Expand Down
63 changes: 63 additions & 0 deletions src/core/server/saved_objects/export/apply_export_hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { SavedObject } from '../../../types';
import { KibanaRequest } from '../../http';
import { SavedObjectsTypeExportHook, SavedObjectsExportContext } from './types';

interface ApplyExportHooksOptions {
objects: SavedObject[];
request: KibanaRequest;
exportHooks: Record<string, SavedObjectsTypeExportHook>;
}

// TODO: doc + add tests.
export const applyExportHooks = async ({
objects,
request,
exportHooks,
}: ApplyExportHooksOptions): Promise<SavedObject[]> => {
const context = createContext(request);
const byType = splitByType(objects);

let finalObjects: SavedObject[] = [];
for (const [type, typeObjs] of Object.entries(byType)) {
const typeHook = exportHooks[type];
if (typeHook) {
finalObjects = [...finalObjects, ...(await typeHook(typeObjs, context))];
} else {
finalObjects = [...finalObjects, ...typeObjs];
}
}

return finalObjects;
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the core of the feature

The hook is defined by

export type SavedObjectsTypeExportHook = <T = unknown>(
  objects: Array<SavedObject<T>>,
  context: SavedObjectsExportContext
) => SavedObject[] | Promise<SavedObject[]>;

Which allows to both:

  • update exported SOs and return updated versions
  • add additional objects to the export

Note that is also allows to filter / exclude objects from the export. This is probably something we do not want, but it seems still alright, and limiting that with a more structured API would need a better structure than just returning a list of SO from the hook.

Does that look alright?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I think of a "hook" I think of a callback that gets called for every model/object, like a "pre-save hook". What do you think about calling this SavedObjectsExportTransform and registerExportTransform?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that is also allows to filter / exclude objects from the export. This is probably something we do not want

I do like the simplicity of this API shape. Instead of solving the filtering caveat with the shape of the return type, any reason we shouldn't prevent accidental filtering at runtime by verifying that all object IDs that were passed in were also in the array that was returned?

When I think of a "hook" I think of a callback that gets called for every model/object, like a "pre-save hook". What do you think about calling this SavedObjectsExportTransform and registerExportTransform?

+1 on not naming this concept "hook". We've had requests in the past for on-save and on-delete hooks and I worry this would confuse developers.

Copy link
Contributor Author

@pgayvallet pgayvallet Jan 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about calling this SavedObjectsExportTransform

I agree. Will rename to transform instead

any reason we shouldn't prevent accidental filtering at runtime by verifying that all object IDs that were passed in were also in the array that was returned?

Yea, we can do that. This will cause the export to fail, but I guess this would be detected during development time and is still better than doing nothing


const createContext = (request: KibanaRequest): SavedObjectsExportContext => {
return {
request,
};
};

const splitByType = (objects: SavedObject[]): Record<string, SavedObject[]> => {
return objects.reduce((memo, obj) => {
memo[obj.type] = [...(memo[obj.type] ?? []), obj];
return memo;
}, {} as Record<string, SavedObject[]>);
};
2 changes: 2 additions & 0 deletions src/core/server/saved_objects/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export {
SavedObjectExportBaseOptions,
SavedObjectsExportByTypeOptions,
SavedObjectsExportResultDetails,
SavedObjectsExportContext,
SavedObjectsTypeExportHook,
} from './types';
export { ISavedObjectsExporter, SavedObjectsExporter } from './saved_objects_exporter';
export { SavedObjectsExportError } from './errors';
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { SavedObjectsExporter } from './saved_objects_exporter';
import { savedObjectsClientMock } from '../service/saved_objects_client.mock';
import { httpServerMock } from '../../http/http_server.mocks';
import { Readable } from 'stream';
import { createPromiseFromStreams, createConcatStream } from '@kbn/utils';

Expand All @@ -27,14 +28,16 @@ async function readStreamToCompletion(stream: Readable) {
}

const exportSizeLimit = 500;
const request = httpServerMock.createKibanaRequest();
const exportHooks = {};

describe('getSortedObjectsForExport()', () => {
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
let exporter: SavedObjectsExporter;

beforeEach(() => {
savedObjectsClient = savedObjectsClientMock.create();
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit });
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, exportHooks });
});

describe('#exportByTypes', () => {
Expand Down Expand Up @@ -67,6 +70,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
});

Expand Down Expand Up @@ -157,6 +161,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
});

Expand Down Expand Up @@ -245,6 +250,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
excludeExportDetails: true,
});
Expand Down Expand Up @@ -304,6 +310,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
search: 'foo',
});
Expand Down Expand Up @@ -386,6 +393,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
hasReference: [
{
Expand Down Expand Up @@ -479,6 +487,7 @@ describe('getSortedObjectsForExport()', () => {
page: 0,
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
namespace: 'foo',
});
Expand Down Expand Up @@ -542,7 +551,7 @@ describe('getSortedObjectsForExport()', () => {
});

test('export selected types throws error when exceeding exportSizeLimit', async () => {
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1 });
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, exportHooks });

savedObjectsClient.find.mockResolvedValueOnce({
total: 2,
Expand Down Expand Up @@ -573,6 +582,7 @@ describe('getSortedObjectsForExport()', () => {
});
await expect(
exporter.exportByTypes({
request,
types: ['index-pattern', 'search'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't export more than 1 objects"`);
Expand Down Expand Up @@ -614,6 +624,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByTypes({
request,
types: ['index-pattern'],
});
const response = await readStreamToCompletion(exportStream);
Expand Down Expand Up @@ -678,6 +689,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByObjects({
request,
objects: [
{
type: 'index-pattern',
Expand Down Expand Up @@ -770,6 +782,7 @@ describe('getSortedObjectsForExport()', () => {
});
await expect(
exporter.exportByObjects({
request,
objects: [
{
type: 'index-pattern',
Expand All @@ -785,9 +798,10 @@ describe('getSortedObjectsForExport()', () => {
});

test('export selected objects throws error when exceeding exportSizeLimit', async () => {
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1 });
exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, exportHooks });

const exportOpts = {
request,
objects: [
{
type: 'index-pattern',
Expand All @@ -814,6 +828,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByObjects({
request,
objects: [
{ type: 'multi', id: '1' },
{ type: 'multi', id: '2' },
Expand Down Expand Up @@ -857,6 +872,7 @@ describe('getSortedObjectsForExport()', () => {
],
});
const exportStream = await exporter.exportByObjects({
request,
objects: [
{
type: 'search',
Expand Down
15 changes: 15 additions & 0 deletions src/core/server/saved_objects/export/saved_objects_exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import {
SavedObjectExportBaseOptions,
SavedObjectsExportByObjectOptions,
SavedObjectsExportByTypeOptions,
SavedObjectsTypeExportHook,
} from './types';
import { SavedObjectsExportError } from './errors';
import { applyExportHooks } from './apply_export_hooks';

/**
* @public
Expand All @@ -40,16 +42,20 @@ export type ISavedObjectsExporter = PublicMethodsOf<SavedObjectsExporter>;
*/
export class SavedObjectsExporter {
readonly #savedObjectsClient: SavedObjectsClientContract;
readonly #exportHooks: Record<string, SavedObjectsTypeExportHook>;
readonly #exportSizeLimit: number;

constructor({
savedObjectsClient,
exportHooks,
exportSizeLimit,
}: {
savedObjectsClient: SavedObjectsClientContract;
exportHooks: Record<string, SavedObjectsTypeExportHook>;
exportSizeLimit: number;
}) {
this.#savedObjectsClient = savedObjectsClient;
this.#exportHooks = exportHooks;
this.#exportSizeLimit = exportSizeLimit;
}

Expand All @@ -63,6 +69,7 @@ export class SavedObjectsExporter {
public async exportByTypes(options: SavedObjectsExportByTypeOptions) {
const objects = await this.fetchByTypes(options);
return this.processObjects(objects, {
request: options.request,
includeReferencesDeep: options.includeReferencesDeep,
excludeExportDetails: options.excludeExportDetails,
namespace: options.namespace,
Expand All @@ -82,6 +89,7 @@ export class SavedObjectsExporter {
}
const objects = await this.fetchByObjects(options);
return this.processObjects(objects, {
request: options.request,
includeReferencesDeep: options.includeReferencesDeep,
excludeExportDetails: options.excludeExportDetails,
namespace: options.namespace,
Expand All @@ -91,6 +99,7 @@ export class SavedObjectsExporter {
private async processObjects(
savedObjects: SavedObject[],
{
request,
excludeExportDetails = false,
includeReferencesDeep = false,
namespace,
Expand All @@ -99,6 +108,12 @@ export class SavedObjectsExporter {
let exportedObjects: Array<SavedObject<unknown>>;
let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = [];

savedObjects = await applyExportHooks({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyExportHooks will change the sorting order. The new sorting order will be consistent, but it would be nice if we could keep the sorting order the same as previous versions because of #29747.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could also add a comment to the code, because it's not so obvious from the existing code why we did this sort in the first place.

Copy link
Contributor Author

@pgayvallet pgayvallet Jan 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great, however as the hooks mutate subsets of the exported objects, I'm not really sure how to do this.

I guess we could create a type:id list of the exported objects before calling the hooks, and then try to reorder the objects based on their position on this type:id list.

  • objects mutated or untouched would preserve their position
  • objects added would be appended at the end of the exported list

Does that sound alright?

Copy link
Contributor

@rudolf rudolf Jan 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized that types using the export hooks would not have ever been exported before, so for version control it won't really matter (it's not impossible for hidden: false objects to also adopt export hooks, but probably unlikely). However, plugins implementing an exportHook might not know/forget to use a stable sort order. So I think it's best if we:

  • keep the current sort in fetchByTypes
  • also sort all objects returned by an export hook (it doesn't really matter which stable sort we use, we could just use id to be consistent, in 8.0.0 it would be nice to rather sort by a new created_at field so that new objects are always at the end of the list, but that would require resorting all objects after export hooks were applied).

Copy link
Contributor Author

@pgayvallet pgayvallet Jan 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized that types using the export hooks would not have ever been exported before, so for version control it won't really matter

The hooks handling logic still changes the position of types not having registered hooks, as it needs to split the exported objects per type and then reconstruct a result array (see the implementation https://github.com/elastic/kibana/pull/87807/files#diff-d6563061094fb926296a7960b2c76ec062d04e63e22646fb48975ab03dde6fe5). So we still need to find a way to reorder all the objects if we want to be compliant with #29747 I think?

objects: savedObjects,
request,
exportHooks: this.#exportHooks,
});

if (includeReferencesDeep) {
const fetchResult = await fetchNestedDependencies(
savedObjects,
Expand Down
20 changes: 19 additions & 1 deletion src/core/server/saved_objects/export/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
* under the License.
*/

import { SavedObjectsFindOptionsReference } from '../types';
import { KibanaRequest } from '../../http';
import { SavedObject, SavedObjectsFindOptionsReference } from '../types';

/** @public */
export interface SavedObjectExportBaseOptions {
/** The http request initiating the export. */
request: KibanaRequest;
/** flag to also include all related saved objects in the export stream. */
includeReferencesDeep?: boolean;
/** flag to not append {@link SavedObjectsExportResultDetails | export details} to the end of the export stream. */
Expand Down Expand Up @@ -75,3 +78,18 @@ export interface SavedObjectsExportResultDetails {
type: string;
}>;
}

/**
* @public
*/
export interface SavedObjectsExportContext {
request: KibanaRequest;
}
joshdover marked this conversation as resolved.
Show resolved Hide resolved

/**
* @public
*/
export type SavedObjectsTypeExportHook = <T = unknown>(
objects: Array<SavedObject<T>>,
context: SavedObjectsExportContext
) => SavedObject[] | Promise<SavedObject[]>;
2 changes: 2 additions & 0 deletions src/core/server/saved_objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export {
SavedObjectsExportByObjectOptions,
SavedObjectsExportResultDetails,
SavedObjectsExportError,
SavedObjectsExportContext,
SavedObjectsTypeExportHook,
} from './export';

export {
Expand Down
11 changes: 9 additions & 2 deletions src/core/server/saved_objects/routes/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { schema } from '@kbn/config-schema';
import stringify from 'json-stable-stringify';
import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils';

import { IRouter } from '../../http';
import { IRouter, KibanaRequest } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import { SavedObjectConfig } from '../saved_objects_config';
import {
Expand Down Expand Up @@ -89,7 +89,11 @@ const validateOptions = (
includeReferencesDeep,
search,
}: ExportOptions,
{ exportSizeLimit, supportedTypes }: { exportSizeLimit: number; supportedTypes: string[] }
{
exportSizeLimit,
supportedTypes,
request,
}: { exportSizeLimit: number; supportedTypes: string[]; request: KibanaRequest }
): EitherExportOptions => {
const hasTypes = (types?.length ?? 0) > 0;
const hasObjects = (objects?.length ?? 0) > 0;
Expand Down Expand Up @@ -117,6 +121,7 @@ const validateOptions = (
objects: objects!,
excludeExportDetails,
includeReferencesDeep,
request,
};
} else {
const validationError = validateTypes(types!, supportedTypes);
Expand All @@ -129,6 +134,7 @@ const validateOptions = (
search,
excludeExportDetails,
includeReferencesDeep,
request,
};
}
};
Expand Down Expand Up @@ -176,6 +182,7 @@ export const registerExportRoute = (
let options: EitherExportOptions;
try {
options = validateOptions(cleaned, {
request: req,
exportSizeLimit: maxImportExportSize,
supportedTypes,
});
Expand Down
Loading