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

Conversation

pgayvallet
Copy link
Contributor

@pgayvallet pgayvallet commented Jan 11, 2021

Summary

Fix #84980

This PR adds per-type export transformation during the SO export generation. Type owners can now register a transformer function using the new SavedObjectType.onExport property when registering their type via registerType.

The transformers allow to perform any number of the following operations:

  • Mutate the exported objects associated with the transform
  • Add additional objects to the list of exported objects

API usage examples

Mutating some of the exported objects attributes

savedObjects.registerType({
  name: 'my-type',
  // [...]
  management: {
    importableAndExportable: true,
    onExport: (ctx, objs) => {
      return objs.map((obj) => ({
        ...obj,
        attributes: {
          ...obj.attributes,
          enabled: false,
        },
      }));
    },
  },
});

Adding additional objects to the export

const savedObjectStartContractPromise = getStartServices().then(
    ([{ savedObjects: savedObjectsStart }]) => savedObjectsStart
);

savedObjects.registerType({
  name: 'my-type',
  // [...]
  management: {
    importableAndExportable: true,
    onExport: async (ctx, objs) => {
      const { getScopedClient } = await savedObjectStartContractPromise;
      const client = getScopedClient(ctx.request);
      const objRefs = objs.map(({ id, type }) => ({ id, type }));
      const depResponse = await client.find({
        type: 'my-dep-type',
        hasReference: objRefs,
      });
      return [...objs, ...depResponse.saved_objects];
    },
  },
});

Checklist

Copy link
Contributor Author

@pgayvallet pgayvallet left a comment

Choose a reason for hiding this comment

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

This is a POC of the proposed implementation of #84980,

@joshdover @kobelb @rudolf and overall @elastic/kibana-core: opinions and feedback would be appreciated before I go further.

Comment on lines 31 to 50
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

src/core/server/saved_objects/export/types.ts Outdated Show resolved Hide resolved
Comment on lines 152 to 159
* ```
*/
registerType: (type: SavedObjectsType) => void;

/**
* TODO: documentation
*/
registerExportHook: (type: string, exportHook: SavedObjectsTypeExportHook) => void;
Copy link
Contributor Author

@pgayvallet pgayvallet Jan 11, 2021

Choose a reason for hiding this comment

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

As discussed in #84980 (comment), The hooks are registered via the setup contract, but using a distinct API instead of being included in the type when registering them via registerType. See the comment on #84980 for the (still opened to discussion) reasoning behind that decision.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note: after discussion starting at #84980 (comment), registerExportHook will be removed in favor of registering the transform via SavedObjectType.onExport

Comment on lines 355 to 361
registerExportHook: (type, hook) => {
if (this.started) {
throw new Error('cannot call `registerExportHook` after service startup.');
}
// TODO
this.exportHooks.set(type, hook);
},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As registerExportHook is dissociated from registerType, it is theoretically possible to register multiple hooks for the same type. However this would create a lot of complexity (order of execution, recursivity...), so I think we should just forbid that and throw in that case?

Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably validate that the type has management.importableAndExportable: true if it specifies an export hook.

@pgayvallet pgayvallet added Feature:Saved Objects release_note:skip Skip the PR/issue when compiling release notes Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v7.12.0 v8.0.0 labels Jan 12, 2021
Copy link
Contributor

@kobelb kobelb left a comment

Choose a reason for hiding this comment

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

Just one nit, but besides that, I like it 🥇

@@ -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

@@ -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?

@pgayvallet
Copy link
Contributor Author

Thanks guys for the feedback. As everyone seems alright with the overall implementation, I will continue in this direction.

Copy link
Contributor Author

@pgayvallet pgayvallet left a comment

Choose a reason for hiding this comment

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

Self review

Comment on lines 11 to +12
import { ConfigPath } from '@kbn/config';
import { DetailedPeerCertificate } from 'tls';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Even with the changes I did in src/core/server/types.ts to remove some exports that were only server-side, it seems that we are leaking server-side types to the public definition. This seems caused by the addition of request: KibanaRequest to SavedObjectExportBaseOptions, however I don't see this type exported from server/types or imported anywhere from server/index... If someone got better eyes that I do...

Comment on lines +91 to +102
const assertValidTransform = (transformedObjects: SavedObject[], initialKeys: string[]) => {
const transformedKeys = transformedObjects.map(getObjKey);
const missingKeys: string[] = [];
initialKeys.forEach((initialKey) => {
if (!transformedKeys.includes(initialKey)) {
missingKeys.push(initialKey);
}
});
if (missingKeys.length) {
throw SavedObjectsExportError.invalidTransformError(missingKeys);
}
};
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did not check that objects added to the export are all importableAndExportable: true. With current implementation you can export 'non-exportable' objects, and then when trying to import them back, it will fail because the types are not importable. However this highly depends on #82027, and I think this should be addressed at the same time.

Should I still add importableAndExportable check during assertValidTransform in this PR?

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 For leaving out the validation since we will anyway support importing these objects in the same release.

Comment on lines +41 to +44
export const getPreservedOrderComparator = (objects: SavedObject[]): SavedObjectComparator => {
const orderedKeys = objects.map(getObjKey);
return (a: SavedObject, b: SavedObject) => {
const indexA = orderedKeys.indexOf(getObjKey(a));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed in #87807 (comment), the export hooks can alter the order of the exported objects, even when no transform are registered for the exported types. For exportByType, the objects were ordered by ID, so we are just re-doing that after executing the transformations. For exportById, the order was the same as the list of the objects to export. This is what this getPreservedOrderComparator is used for.

Comment on lines -22 to +25
export * from './saved_objects/types';
export type {
SavedObjectsImportResponse,
SavedObjectsImportSuccess,
SavedObjectsImportConflictError,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We were exporting more than necessary to the client-side (such as SavedObjectsType or SavedObjectsTypeManagementDefinition), so I switched to explicit export instead of *

Comment on lines 73 to 82
onExport: async (ctx, objs) => {
const { getScopedClient } = await savedObjectStartContractPromise;
const client = getScopedClient(ctx.request);
const objRefs = objs.map(({ id, type }) => ({ id, type }));
const depResponse = await client.find({
type: 'test-export-add-dep',
hasReference: objRefs,
});
return [...objs, ...depResponse.saved_objects];
},
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 (naive) test example should be very close to what we will need to do with case and case-comment

@pgayvallet pgayvallet marked this pull request as ready for review January 18, 2021 15:32
@pgayvallet pgayvallet requested review from a team as code owners January 18, 2021 15:32
@botelastic botelastic bot added the Feature:Embedding Embedding content via iFrame label Jan 19, 2021
Copy link
Contributor

@rudolf rudolf left a comment

Choose a reason for hiding this comment

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

Did a quick pass and this was the only thing that stood out to me

Copy link
Contributor

@rudolf rudolf left a comment

Choose a reason for hiding this comment

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

I think it's worth adding an integration test for transform failures, but otherwise 👍

Copy link
Contributor

@jportner jportner left a comment

Choose a reason for hiding this comment

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

Spaces changes LGTM.

Did not test locally but I took a high level pass on the Core changes too, nothing stood out to me there.

@kibanamachine
Copy link
Contributor

💚 Build Succeeded

Metrics [docs]

Module Count

Fewer modules leads to a faster build time

id before after diff
core 447 445 -2

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
core 563.8KB 561.6KB -2.3KB

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

@pgayvallet pgayvallet merged commit 477d0bb into elastic:master Jan 21, 2021
pgayvallet added a commit to pgayvallet/kibana that referenced this pull request Jan 21, 2021
* initial POC

* fix spaces UT

* address POC feedback, add tests for applyExportTransforms

* add sorting for transforms

* add type validation in SOTR

* add FTR tests

* update documentation

* add explicit so type export for client-side

* update generated doc

* add exporter test

* update license headers

* update generated doc

* fix so import... imports

* update generated doc

* nits

* update generated doc

* rename test plugins

* adding FTR tests on export failures
pgayvallet added a commit that referenced this pull request Jan 21, 2021
* add SavedObject export hooks (#87807)

* initial POC

* fix spaces UT

* address POC feedback, add tests for applyExportTransforms

* add sorting for transforms

* add type validation in SOTR

* add FTR tests

* update documentation

* add explicit so type export for client-side

* update generated doc

* add exporter test

* update license headers

* update generated doc

* fix so import... imports

* update generated doc

* nits

* update generated doc

* rename test plugins

* adding FTR tests on export failures

* fix data for 7.x
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature:Embedding Embedding content via iFrame Feature:Saved Objects release_note:skip Skip the PR/issue when compiling release notes Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v7.12.0 v8.0.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Saved-object export custom logic
7 participants