-
Notifications
You must be signed in to change notification settings - Fork 8.2k
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
add SavedObject export hooks #87807
Conversation
There was a problem hiding this 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.
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.
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?
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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
andregisterExportTransform
?
+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.
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
* ``` | ||
*/ | ||
registerType: (type: SavedObjectsType) => void; | ||
|
||
/** | ||
* TODO: documentation | ||
*/ | ||
registerExportHook: (type: string, exportHook: SavedObjectsTypeExportHook) => void; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this 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, |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 newcreated_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).
There was a problem hiding this comment.
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?
Thanks guys for the feedback. As everyone seems alright with the overall implementation, I will continue in this direction. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Self review
import { ConfigPath } from '@kbn/config'; | ||
import { DetailedPeerCertificate } from 'tls'; |
There was a problem hiding this comment.
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...
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.
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?
There was a problem hiding this comment.
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.
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.
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.
export * from './saved_objects/types'; | ||
export type { | ||
SavedObjectsImportResponse, | ||
SavedObjectsImportSuccess, | ||
SavedObjectsImportConflictError, |
There was a problem hiding this comment.
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 *
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.
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
There was a problem hiding this 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
test/plugin_functional/test_suites/saved_objects_management/export_transform.ts
Show resolved
Hide resolved
There was a problem hiding this 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 👍
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
There was a problem hiding this 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.
💚 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.onExport
property 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