add SavedObject export hooks#87807
Conversation
pgayvallet
left a comment
There was a problem hiding this comment.
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.
| 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; | ||
| }; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
SavedObjectsExportTransformandregisterExportTransform?
+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.
There was a problem hiding this comment.
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
| * ``` | ||
| */ | ||
| registerType: (type: SavedObjectsType) => void; | ||
|
|
||
| /** | ||
| * TODO: documentation | ||
| */ | ||
| registerExportHook: (type: string, exportHook: SavedObjectsTypeExportHook) => void; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Note: after discussion starting at #84980 (comment), registerExportHook will be removed in favor of registering the transform via SavedObjectType.onExport
| registerExportHook: (type, hook) => { | ||
| if (this.started) { | ||
| throw new Error('cannot call `registerExportHook` after service startup.'); | ||
| } | ||
| // TODO | ||
| this.exportHooks.set(type, hook); | ||
| }, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
We should probably validate that the type has management.importableAndExportable: true if it specifies an export hook.
kobelb
left a comment
There was a problem hiding this comment.
Just one nit, but besides that, I like it 🥇
| setClientFactoryProvider: setupDeps.core.savedObjects.setClientFactoryProvider, | ||
| addClientWrapper: setupDeps.core.savedObjects.addClientWrapper, | ||
| registerType: setupDeps.core.savedObjects.registerType, | ||
| registerExportHook: setupDeps.core.savedObjects.registerExportHook, |
There was a problem hiding this comment.
nit: instead of having a dedicated registerExportHook, should we make it part of the existing SavedObjectsTypeManagementDefinition interface since this already has importableAndExportable?
There was a problem hiding this comment.
Yea, we came with the same conclusion with @rudolf in #84980 (comment). I will adapt that, as I did in #87996
| let exportedObjects: Array<SavedObject<unknown>>; | ||
| let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; | ||
|
|
||
| savedObjects = await applyExportHooks({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
idto be consistent, in 8.0.0 it would be nice to rather sort by a newcreated_atfield so that new objects are always at the end of the list, but that would require resorting all objects after export hooks were applied).
There was a problem hiding this comment.
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?
|
Thanks guys for the feedback. As everyone seems alright with the overall implementation, I will continue in this direction. |
| import { ConfigPath } from '@kbn/config'; | ||
| import { DetailedPeerCertificate } from 'tls'; |
There was a problem hiding this comment.
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...
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
+1 For leaving out the validation since we will anyway support importing these objects in the same release.
| export const getPreservedOrderComparator = (objects: SavedObject[]): SavedObjectComparator => { | ||
| const orderedKeys = objects.map(getObjKey); | ||
| return (a: SavedObject, b: SavedObject) => { | ||
| const indexA = orderedKeys.indexOf(getObjKey(a)); |
There was a problem hiding this comment.
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.
| export * from './saved_objects/types'; | ||
| export type { | ||
| SavedObjectsImportResponse, | ||
| SavedObjectsImportSuccess, | ||
| SavedObjectsImportConflictError, |
There was a problem hiding this comment.
We were exporting more than necessary to the client-side (such as SavedObjectsType or SavedObjectsTypeManagementDefinition), so I switched to explicit export instead of *
| 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]; | ||
| }, |
There was a problem hiding this comment.
This (naive) test example should be very close to what we will need to do with case and case-comment
|
Pinging @elastic/kibana-core (Team:Core) |
rudolf
left a comment
There was a problem hiding this comment.
Did a quick pass and this was the only thing that stood out to me
test/plugin_functional/test_suites/saved_objects_management/export_transform.ts
Show resolved
Hide resolved
rudolf
left a comment
There was a problem hiding this comment.
I think it's worth adding an integration test for transform failures, but otherwise 👍
docs/development/core/server/kibana-plugin-core-server.savedobjectsexporttransform.md
Show resolved
Hide resolved
src/core/server/saved_objects/saved_objects_type_registry.test.ts
Outdated
Show resolved
Hide resolved
jportner
left a comment
There was a problem hiding this comment.
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.
💚 Build SucceededMetrics [docs]Module Count
Page load bundle
History
To update your PR or re-run it, just comment with: |
* 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
* 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
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.onExportproperty when registering their type viaregisterType.The transformers allow to perform any number of the following operations:
API usage examples
Mutating some of the exported objects attributes
Adding additional objects to the export
Checklist