diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md
index 604cdea1a7fbe..ac8930c52ac5c 100644
--- a/docs/development/core/server/kibana-plugin-core-server.md
+++ b/docs/development/core/server/kibana-plugin-core-server.md
@@ -165,6 +165,7 @@ The plugin integrates with the core system via lifecycle events: `setup`
| [SavedObjectsDeleteOptions](./kibana-plugin-core-server.savedobjectsdeleteoptions.md) | |
| [SavedObjectsExportByObjectOptions](./kibana-plugin-core-server.savedobjectsexportbyobjectoptions.md) | Options for the [export by objects API](./kibana-plugin-core-server.savedobjectsexporter.exportbyobjects.md) |
| [SavedObjectsExportByTypeOptions](./kibana-plugin-core-server.savedobjectsexportbytypeoptions.md) | Options for the [export by type API](./kibana-plugin-core-server.savedobjectsexporter.exportbytypes.md) |
+| [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md) | |
| [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) | Structure of the export result details entry |
| [SavedObjectsExportTransformContext](./kibana-plugin-core-server.savedobjectsexporttransformcontext.md) | Context passed down to a [export transform function](./kibana-plugin-core-server.savedobjectsexporttransform.md) |
| [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) | |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.id.md
new file mode 100644
index 0000000000000..f7b96e71c8e53
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.id.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md) > [id](./kibana-plugin-core-server.savedobjectsexportexcludedobject.id.md)
+
+## SavedObjectsExportExcludedObject.id property
+
+id of the excluded object
+
+Signature:
+
+```typescript
+id: string;
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.md
new file mode 100644
index 0000000000000..4766ae25a936d
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.md
@@ -0,0 +1,21 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md)
+
+## SavedObjectsExportExcludedObject interface
+
+
+Signature:
+
+```typescript
+export interface SavedObjectsExportExcludedObject
+```
+
+## Properties
+
+| Property | Type | Description |
+| --- | --- | --- |
+| [id](./kibana-plugin-core-server.savedobjectsexportexcludedobject.id.md) | string
| id of the excluded object |
+| [reason](./kibana-plugin-core-server.savedobjectsexportexcludedobject.reason.md) | string
| optional cause of the exclusion |
+| [type](./kibana-plugin-core-server.savedobjectsexportexcludedobject.type.md) | string
| type of the excluded object |
+
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.reason.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.reason.md
new file mode 100644
index 0000000000000..0adb1ba35e696
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.reason.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md) > [reason](./kibana-plugin-core-server.savedobjectsexportexcludedobject.reason.md)
+
+## SavedObjectsExportExcludedObject.reason property
+
+optional cause of the exclusion
+
+Signature:
+
+```typescript
+reason?: string;
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.type.md
new file mode 100644
index 0000000000000..be28ac2d0ffb6
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportexcludedobject.type.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsExportExcludedObject](./kibana-plugin-core-server.savedobjectsexportexcludedobject.md) > [type](./kibana-plugin-core-server.savedobjectsexportexcludedobject.type.md)
+
+## SavedObjectsExportExcludedObject.type property
+
+type of the excluded object
+
+Signature:
+
+```typescript
+type: string;
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjects.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjects.md
new file mode 100644
index 0000000000000..90432bf6d6705
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjects.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) > [excludedObjects](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjects.md)
+
+## SavedObjectsExportResultDetails.excludedObjects property
+
+excluded objects details
+
+Signature:
+
+```typescript
+excludedObjects: SavedObjectsExportExcludedObject[];
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjectscount.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjectscount.md
new file mode 100644
index 0000000000000..05846e28b9cab
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjectscount.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) > [excludedObjectsCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjectscount.md)
+
+## SavedObjectsExportResultDetails.excludedObjectsCount property
+
+number of objects that were excluded from the export
+
+Signature:
+
+```typescript
+excludedObjectsCount: number;
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.md
index d98088c5f45be..f017f2329170b 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexportresultdetails.md
@@ -16,6 +16,8 @@ export interface SavedObjectsExportResultDetails
| Property | Type | Description |
| --- | --- | --- |
+| [excludedObjects](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjects.md) | SavedObjectsExportExcludedObject[]
| excluded objects details |
+| [excludedObjectsCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.excludedobjectscount.md) | number
| number of objects that were excluded from the export |
| [exportedCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.exportedcount.md) | number
| number of successfully exported objects |
| [missingRefCount](./kibana-plugin-core-server.savedobjectsexportresultdetails.missingrefcount.md) | number
| number of missing references |
| [missingReferences](./kibana-plugin-core-server.savedobjectsexportresultdetails.missingreferences.md) | Array<{
id: string;
type: string;
}>
| missing references details |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporttransform.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporttransform.md
index 50d4c5425e8fd..2effed1ae9d70 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporttransform.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporttransform.md
@@ -11,7 +11,7 @@ A type's export transform function will be executed once per user-initiated expo
Signature:
```typescript
-export declare type SavedObjectsExportTransform = (context: SavedObjectsExportTransformContext, objects: Array>) => SavedObject[] | Promise;
+export declare type SavedObjectsExportTransform = (context: SavedObjectsExportTransformContext, objects: Array>) => SavedObject[] | Promise;
```
## Remarks
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.md
index 56ebb48707f59..a1bc99ce8d13d 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.md
@@ -52,6 +52,6 @@ export class Plugin() {
| Property | Type | Description |
| --- | --- | --- |
| [addClientWrapper](./kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md) | (priority: number, id: string, factory: SavedObjectsClientWrapperFactory) => void
| Add a [client wrapper factory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) with the given priority. |
-| [registerType](./kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | (type: SavedObjectsType) => void
| Register a [savedObjects type](./kibana-plugin-core-server.savedobjectstype.md) definition.See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) and [migration format](./kibana-plugin-core-server.savedobjectmigrationmap.md) for more details about these. |
+| [registerType](./kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | <Attributes = any>(type: SavedObjectsType<Attributes>) => void
| Register a [savedObjects type](./kibana-plugin-core-server.savedobjectstype.md) definition.See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) and [migration format](./kibana-plugin-core-server.savedobjectmigrationmap.md) for more details about these. |
| [setClientFactoryProvider](./kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | (clientFactoryProvider: SavedObjectsClientFactoryProvider) => void
| Set the default [factory provider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) for creating Saved Objects clients. Only one provider can be set, subsequent calls to this method will fail. |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md
index 54e01d3110a2d..7f74ce4d7bea7 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md
@@ -11,7 +11,7 @@ See the [mappings format](./kibana-plugin-core-server.savedobjectstypemappingdef
Signature:
```typescript
-registerType: (type: SavedObjectsType) => void;
+registerType: (type: SavedObjectsType) => void;
```
## Example
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.management.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.management.md
index fbaf58f959075..d98c553656b1f 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.management.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.management.md
@@ -9,5 +9,5 @@ An optional [saved objects management section](./kibana-plugin-core-server.saved
Signature:
```typescript
-management?: SavedObjectsTypeManagementDefinition;
+management?: SavedObjectsTypeManagementDefinition;
```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md
index d882938d731c8..c3aba5261561f 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md
@@ -7,7 +7,7 @@
Signature:
```typescript
-export interface SavedObjectsType
+export interface SavedObjectsType
```
## Remarks
@@ -54,7 +54,7 @@ Example after converting to a multi-namespace (shareable) type in 8.1:
Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. |
| [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | boolean
| Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType
when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). |
| [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string
| If defined, the type instances will be stored in the given index instead of the default one. |
-| [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition
| An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. |
+| [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition<Attributes>
| An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. |
| [mappings](./kibana-plugin-core-server.savedobjectstype.mappings.md) | SavedObjectsTypeMappingDefinition
| The [mapping definition](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) for the type. |
| [migrations](./kibana-plugin-core-server.savedobjectstype.migrations.md) | SavedObjectMigrationMap | (() => SavedObjectMigrationMap)
| An optional map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) or a function returning a map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used to migrate the type. |
| [name](./kibana-plugin-core-server.savedobjectstype.name.md) | string
| The name of the type, which is also used as the internal id. |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md
index f5488d8f0310d..75f820d7a8e56 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md
@@ -9,5 +9,5 @@ Function returning the url to use to redirect to the editing page of this object
Signature:
```typescript
-getEditUrl?: (savedObject: SavedObject) => string;
+getEditUrl?: (savedObject: SavedObject) => string;
```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md
index 7b31dda402571..d6d50840aaadb 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md
@@ -9,7 +9,7 @@ Function returning the url to use to redirect to this object from the management
Signature:
```typescript
-getInAppUrl?: (savedObject: SavedObject) => {
+getInAppUrl?: (savedObject: SavedObject) => {
path: string;
uiCapabilitiesPath: string;
};
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md
index 2f39acc66f451..75784666ef963 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md
@@ -9,5 +9,5 @@ Function returning the title to display in the management table. If not defined,
Signature:
```typescript
-getTitle?: (savedObject: SavedObject) => string;
+getTitle?: (savedObject: SavedObject) => string;
```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.isexportable.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.isexportable.md
new file mode 100644
index 0000000000000..fef178e1d9847
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.isexportable.md
@@ -0,0 +1,49 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsTypeManagementDefinition](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) > [isExportable](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.isexportable.md)
+
+## SavedObjectsTypeManagementDefinition.isExportable property
+
+Optional hook to specify whether an object should be exportable.
+
+If specified, `isExportable` will be called during export for each of this type's objects in the export, and the ones not matching the predicate will be excluded from the export.
+
+When implementing both `isExportable` and `onExport`, it is mandatory that `isExportable` returns the same value for an object before and after going though the export transform. E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
+
+Signature:
+
+```typescript
+isExportable?: SavedObjectsExportablePredicate;
+```
+
+## Remarks
+
+`importableAndExportable` must be `true` to specify this property.
+
+## Example
+
+Registering a type with a per-object exportability predicate
+
+```ts
+// src/plugins/my_plugin/server/plugin.ts
+import { myType } from './saved_objects';
+
+export class Plugin() {
+ setup: (core: CoreSetup) => {
+ core.savedObjects.registerType({
+ ...myType,
+ management: {
+ ...myType.management,
+ isExportable: (object) => {
+ if (object.attributes.myCustomAttr === 'foo') {
+ return false;
+ }
+ return true;
+ }
+ },
+ });
+ }
+}
+
+```
+
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.md
index e9cc2b12108d6..8c42884eb0b31 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.md
@@ -9,7 +9,7 @@ Configuration options for the [type](./kibana-plugin-core-server.savedobjectstyp
Signature:
```typescript
-export interface SavedObjectsTypeManagementDefinition
+export interface SavedObjectsTypeManagementDefinition
```
## Properties
@@ -17,11 +17,12 @@ export interface SavedObjectsTypeManagementDefinition
| Property | Type | Description |
| --- | --- | --- |
| [defaultSearchField](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.defaultsearchfield.md) | string
| The default search field to use for this type. Defaults to id
. |
-| [getEditUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md) | (savedObject: SavedObject<any>) => string
| Function returning the url to use to redirect to the editing page of this object. If not defined, editing will not be allowed. |
-| [getInAppUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md) | (savedObject: SavedObject<any>) => {
path: string;
uiCapabilitiesPath: string;
}
| Function returning the url to use to redirect to this object from the management section. If not defined, redirecting to the object will not be allowed. |
-| [getTitle](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md) | (savedObject: SavedObject<any>) => string
| Function returning the title to display in the management table. If not defined, will use the object's type and id to generate a label. |
+| [getEditUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getediturl.md) | (savedObject: SavedObject<Attributes>) => string
| Function returning the url to use to redirect to the editing page of this object. If not defined, editing will not be allowed. |
+| [getInAppUrl](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.getinappurl.md) | (savedObject: SavedObject<Attributes>) => {
path: string;
uiCapabilitiesPath: string;
}
| Function returning the url to use to redirect to this object from the management section. If not defined, redirecting to the object will not be allowed. |
+| [getTitle](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.gettitle.md) | (savedObject: SavedObject<Attributes>) => string
| Function returning the title to display in the management table. If not defined, will use the object's type and id to generate a label. |
| [icon](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.icon.md) | string
| The eui icon name to display in the management table. If not defined, the default icon will be used. |
| [importableAndExportable](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.importableandexportable.md) | boolean
| Is the type importable or exportable. Defaults to false
. |
-| [onExport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md) | SavedObjectsExportTransform
| An optional export transform function that can be used transform the objects of the registered type during the export process.It can be used to either mutate the exported objects, or add additional objects (of any type) to the export list.See [the transform type documentation](./kibana-plugin-core-server.savedobjectsexporttransform.md) for more info and examples. |
-| [onImport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md) | SavedObjectsImportHook
| An optional [import hook](./kibana-plugin-core-server.savedobjectsimporthook.md) to use when importing given type.Import hooks are executed during the savedObjects import process and allow to interact with the imported objects. See the [hook documentation](./kibana-plugin-core-server.savedobjectsimporthook.md) for more info. |
+| [isExportable](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.isexportable.md) | SavedObjectsExportablePredicate<Attributes>
| Optional hook to specify whether an object should be exportable.If specified, isExportable
will be called during export for each of this type's objects in the export, and the ones not matching the predicate will be excluded from the export.When implementing both isExportable
and onExport
, it is mandatory that isExportable
returns the same value for an object before and after going though the export transform. E.g isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)
|
+| [onExport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md) | SavedObjectsExportTransform<Attributes>
| An optional export transform function that can be used transform the objects of the registered type during the export process.It can be used to either mutate the exported objects, or add additional objects (of any type) to the export list.See [the transform type documentation](./kibana-plugin-core-server.savedobjectsexporttransform.md) for more info and examples.When implementing both isExportable
and onExport
, it is mandatory that isExportable
returns the same value for an object before and after going though the export transform. E.g isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)
|
+| [onImport](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md) | SavedObjectsImportHook<Attributes>
| An optional [import hook](./kibana-plugin-core-server.savedobjectsimporthook.md) to use when importing given type.Import hooks are executed during the savedObjects import process and allow to interact with the imported objects. See the [hook documentation](./kibana-plugin-core-server.savedobjectsimporthook.md) for more info. |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md
index 6302b36a73c68..a0d41d2d64967 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onexport.md
@@ -10,10 +10,12 @@ It can be used to either mutate the exported objects, or add additional objects
See [the transform type documentation](./kibana-plugin-core-server.savedobjectsexporttransform.md) for more info and examples.
+When implementing both `isExportable` and `onExport`, it is mandatory that `isExportable` returns the same value for an object before and after going though the export transform. E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
+
Signature:
```typescript
-onExport?: SavedObjectsExportTransform;
+onExport?: SavedObjectsExportTransform;
```
## Remarks
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md
index f6634c01c66ba..332247b8eb8e1 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.onimport.md
@@ -11,7 +11,7 @@ Import hooks are executed during the savedObjects import process and allow to in
Signature:
```typescript
-onImport?: SavedObjectsImportHook;
+onImport?: SavedObjectsImportHook;
```
## Remarks
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getalltypes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getalltypes.md
index c839dd16d9a47..20d631ff74aca 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getalltypes.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getalltypes.md
@@ -11,9 +11,9 @@ To only get the visible types (which is the most common use case), use `getVisib
Signature:
```typescript
-getAllTypes(): SavedObjectsType[];
+getAllTypes(): SavedObjectsType[];
```
Returns:
-`SavedObjectsType[]`
+`SavedObjectsType[]`
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getimportableandexportabletypes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getimportableandexportabletypes.md
index ab8a79c3a8455..1e29e632a6ec3 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getimportableandexportabletypes.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getimportableandexportabletypes.md
@@ -9,9 +9,9 @@ Return all [types](./kibana-plugin-core-server.savedobjectstype.md) currently re
Signature:
```typescript
-getImportableAndExportableTypes(): SavedObjectsType[];
+getImportableAndExportableTypes(): SavedObjectsType[];
```
Returns:
-`SavedObjectsType[]`
+`SavedObjectsType[]`
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.gettype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.gettype.md
index cfa52882bb89d..160aadb73cced 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.gettype.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.gettype.md
@@ -9,7 +9,7 @@ Return the [type](./kibana-plugin-core-server.savedobjectstype.md) definition fo
Signature:
```typescript
-getType(type: string): SavedObjectsType | undefined;
+getType(type: string): SavedObjectsType | undefined;
```
## Parameters
@@ -20,5 +20,5 @@ getType(type: string): SavedObjectsType | undefined;
Returns:
-`SavedObjectsType | undefined`
+`SavedObjectsType | undefined`
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md
index a773c6a0a674f..05f22dcf7010b 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md
@@ -11,9 +11,9 @@ A visible type is a type that doesn't explicitly define `hidden=true` during reg
Signature:
```typescript
-getVisibleTypes(): SavedObjectsType[];
+getVisibleTypes(): SavedObjectsType[];
```
Returns:
-`SavedObjectsType[]`
+`SavedObjectsType[]`
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index ef1ee69ff529b..77946e15ef686 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -295,6 +295,7 @@ export type {
SavedObjectsCreatePointInTimeFinderOptions,
SavedObjectsCreateOptions,
SavedObjectsExportResultDetails,
+ SavedObjectsExportExcludedObject,
SavedObjectsFindResult,
SavedObjectsFindResponse,
SavedObjectsImportConflictError,
diff --git a/src/core/server/saved_objects/export/apply_export_transforms.test.ts b/src/core/server/saved_objects/export/apply_export_transforms.test.ts
index 95c6bd80a1ac3..ed428ef5759a8 100644
--- a/src/core/server/saved_objects/export/apply_export_transforms.test.ts
+++ b/src/core/server/saved_objects/export/apply_export_transforms.test.ts
@@ -27,6 +27,8 @@ const createTransform = (
implementation: SavedObjectsExportTransform = (ctx, objs) => objs
): jest.MockedFunction => jest.fn(implementation);
+const toMap = (record: Record): Map => new Map(Object.entries(record));
+
const expectedContext = {
request: expect.any(KibanaRequest),
};
@@ -49,10 +51,10 @@ describe('applyExportTransforms', () => {
await applyExportTransforms({
request,
objects: [foo1, bar1, foo2],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
bar: barTransform,
- },
+ }),
});
expect(fooTransform).toHaveBeenCalledTimes(1);
@@ -71,10 +73,10 @@ describe('applyExportTransforms', () => {
await applyExportTransforms({
request,
objects: [foo1],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
bar: barTransform,
- },
+ }),
});
expect(fooTransform).toHaveBeenCalledTimes(1);
@@ -100,10 +102,10 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, bar1, foo2],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
bar: barTransform,
- },
+ }),
});
expect(result).toEqual([foo1, foo2, dolly1, bar1, hello1]);
@@ -123,9 +125,9 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, foo2, bar1, bar2],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
- },
+ }),
});
expect(result).toEqual([foo1, foo2, dolly1, bar1, bar2]);
@@ -150,9 +152,9 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, foo2],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
- },
+ }),
});
expect(result).toEqual([foo1, foo2].map(disableFoo));
@@ -175,10 +177,10 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, bar1],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
bar: barTransform,
- },
+ }),
});
expect(result).toEqual([foo1, dolly1, bar1, hello1]);
@@ -201,10 +203,10 @@ describe('applyExportTransforms', () => {
const result = await applyExportTransforms({
request,
objects: [foo1, bar1],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
bar: barTransform,
- },
+ }),
sortFunction: (obj1, obj2) => (obj1.id > obj2.id ? 1 : -1),
});
@@ -223,9 +225,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1, foo2],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
- },
+ }),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
@@ -247,9 +249,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1, foo2],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
- },
+ }),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
@@ -271,9 +273,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1, foo2],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
- },
+ }),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid transform performed on objects to export"`
@@ -291,9 +293,9 @@ describe('applyExportTransforms', () => {
applyExportTransforms({
request,
objects: [foo1],
- transforms: {
+ transforms: toMap({
foo: fooTransform,
- },
+ }),
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Error transforming objects to export"`);
});
diff --git a/src/core/server/saved_objects/export/apply_export_transforms.ts b/src/core/server/saved_objects/export/apply_export_transforms.ts
index 2a788a32b92f6..78e1dd7d6c117 100644
--- a/src/core/server/saved_objects/export/apply_export_transforms.ts
+++ b/src/core/server/saved_objects/export/apply_export_transforms.ts
@@ -15,7 +15,7 @@ import { getObjKey, SavedObjectComparator } from './utils';
interface ApplyExportTransformsOptions {
objects: SavedObject[];
request: KibanaRequest;
- transforms: Record;
+ transforms: Map;
sortFunction?: SavedObjectComparator;
}
@@ -30,7 +30,7 @@ export const applyExportTransforms = async ({
let finalObjects: SavedObject[] = [];
for (const [type, typeObjs] of Object.entries(byType)) {
- const typeTransformFn = transforms[type];
+ const typeTransformFn = transforms.get(type);
if (typeTransformFn) {
finalObjects = [
...finalObjects,
diff --git a/src/core/server/saved_objects/export/collect_exported_objects.test.ts b/src/core/server/saved_objects/export/collect_exported_objects.test.ts
index 0929ff0d40910..aab9f9134ee2c 100644
--- a/src/core/server/saved_objects/export/collect_exported_objects.test.ts
+++ b/src/core/server/saved_objects/export/collect_exported_objects.test.ts
@@ -9,9 +9,12 @@
import { applyExportTransformsMock } from './collect_exported_objects.test.mocks';
import { savedObjectsClientMock } from '../../mocks';
import { httpServerMock } from '../../http/http_server.mocks';
+import { loggerMock } from '../../logging/logger.mock';
import { SavedObject, SavedObjectError } from '../../../types';
+import { SavedObjectTypeRegistry } from '../saved_objects_type_registry';
import type { SavedObjectsExportTransform } from './types';
-import { collectExportedObjects } from './collect_exported_objects';
+import { collectExportedObjects, ExclusionReason } from './collect_exported_objects';
+import { SavedObjectsExportablePredicate } from '../types';
const createObject = (parts: Partial): SavedObject => ({
id: 'id',
@@ -29,14 +32,48 @@ const createError = (parts: Partial = {}): SavedObjectError =>
});
const toIdTuple = (obj: SavedObject) => ({ type: obj.type, id: obj.id });
+const toExcludedObject = (obj: SavedObject, reason: ExclusionReason = 'excluded') => ({
+ type: obj.type,
+ id: obj.id,
+ reason,
+});
+
+const toMap = (record: Record): Map => new Map(Object.entries(record));
describe('collectExportedObjects', () => {
let savedObjectsClient: ReturnType;
let request: ReturnType;
+ let logger: ReturnType;
+ let typeRegistry: SavedObjectTypeRegistry;
+
+ const registerType = (
+ name: string,
+ {
+ onExport,
+ isExportable,
+ }: {
+ onExport?: SavedObjectsExportTransform;
+ isExportable?: SavedObjectsExportablePredicate;
+ } = {}
+ ) => {
+ typeRegistry.registerType({
+ name,
+ hidden: false,
+ namespaceType: 'single',
+ mappings: { properties: {} },
+ management: {
+ importableAndExportable: true,
+ onExport,
+ isExportable,
+ },
+ });
+ };
beforeEach(() => {
+ typeRegistry = new SavedObjectTypeRegistry();
savedObjectsClient = savedObjectsClientMock.create();
request = httpServerMock.createKibanaRequest();
+ logger = loggerMock.create();
applyExportTransformsMock.mockImplementation(({ objects }) => objects);
savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [] });
});
@@ -58,23 +95,62 @@ describe('collectExportedObjects', () => {
});
const fooTransform: SavedObjectsExportTransform = jest.fn();
+ registerType('foo', { onExport: fooTransform });
await collectExportedObjects({
objects: [obj1, obj2],
savedObjectsClient,
request,
- exportTransforms: { foo: fooTransform },
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(applyExportTransformsMock).toHaveBeenCalledTimes(1);
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [obj1, obj2],
- transforms: { foo: fooTransform },
+ transforms: toMap({ foo: fooTransform }),
request,
});
});
+ it('calls `isExportable` with the correct parameters', async () => {
+ const foo1 = createObject({
+ type: 'foo',
+ id: '1',
+ });
+ const foo2 = createObject({
+ type: 'foo',
+ id: '2',
+ });
+ const bar3 = createObject({
+ type: 'bar',
+ id: '3',
+ });
+
+ const fooExportable: SavedObjectsExportablePredicate = jest.fn().mockReturnValue(true);
+ registerType('foo', { isExportable: fooExportable });
+
+ const barExportable: SavedObjectsExportablePredicate = jest.fn().mockReturnValue(true);
+ registerType('bar', { isExportable: barExportable });
+
+ await collectExportedObjects({
+ objects: [foo1, foo2, bar3],
+ savedObjectsClient,
+ request,
+ typeRegistry,
+ includeReferences: true,
+ logger,
+ });
+
+ expect(fooExportable).toHaveBeenCalledTimes(2);
+ expect(fooExportable).toHaveBeenCalledWith(foo1);
+ expect(fooExportable).toHaveBeenCalledWith(foo2);
+
+ expect(barExportable).toHaveBeenCalledTimes(1);
+ expect(barExportable).toHaveBeenCalledWith(bar3);
+ });
+
it('returns the collected objects', async () => {
const foo1 = createObject({
type: 'foo',
@@ -96,6 +172,10 @@ describe('collectExportedObjects', () => {
id: '3',
});
+ registerType('foo');
+ registerType('bar');
+ registerType('dolly');
+
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2],
@@ -105,14 +185,220 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(missingRefs).toHaveLength(0);
expect(objects.map(toIdTuple)).toEqual([foo1, dolly3, bar2].map(toIdTuple));
});
+ it('excludes objects filtered by the `isExportable` predicate', async () => {
+ const foo1 = createObject({
+ type: 'foo',
+ id: '1',
+ });
+ const foo2 = createObject({
+ type: 'foo',
+ id: '2',
+ });
+ const bar3 = createObject({
+ type: 'bar',
+ id: '3',
+ });
+
+ registerType('foo', { isExportable: (obj) => obj.id !== '2' });
+ registerType('bar', { isExportable: () => true });
+
+ const { objects, excludedObjects } = await collectExportedObjects({
+ objects: [foo1, foo2, bar3],
+ savedObjectsClient,
+ request,
+ typeRegistry,
+ includeReferences: true,
+ logger,
+ });
+
+ expect(objects).toEqual([foo1, bar3]);
+ expect(excludedObjects).toEqual([foo2].map((obj) => toExcludedObject(obj)));
+ });
+
+ it('excludes objects when the predicate throws', async () => {
+ const foo1 = createObject({
+ type: 'foo',
+ id: '1',
+ });
+ const foo2 = createObject({
+ type: 'foo',
+ id: '2',
+ });
+ const bar3 = createObject({
+ type: 'bar',
+ id: '3',
+ });
+
+ registerType('foo', {
+ isExportable: (obj) => {
+ if (obj.id === '1') {
+ throw new Error('reason');
+ }
+ return true;
+ },
+ });
+ registerType('bar', { isExportable: () => true });
+
+ const { objects, excludedObjects } = await collectExportedObjects({
+ objects: [foo1, foo2, bar3],
+ savedObjectsClient,
+ request,
+ typeRegistry,
+ includeReferences: true,
+ logger,
+ });
+
+ expect(objects).toEqual([foo2, bar3]);
+ expect(excludedObjects).toEqual(
+ [foo1].map((obj) => toExcludedObject(obj, 'predicate_error'))
+ );
+ });
+
+ it('logs an error for each predicate error', async () => {
+ const foo1 = createObject({
+ type: 'foo',
+ id: '1',
+ });
+ const foo2 = createObject({
+ type: 'foo',
+ id: '2',
+ });
+ const foo3 = createObject({
+ type: 'foo',
+ id: '3',
+ });
+
+ registerType('foo', {
+ isExportable: (obj) => {
+ if (obj.id !== '2') {
+ throw new Error('reason');
+ }
+ return true;
+ },
+ });
+
+ const { objects, excludedObjects } = await collectExportedObjects({
+ objects: [foo1, foo2, foo3],
+ savedObjectsClient,
+ request,
+ typeRegistry,
+ includeReferences: true,
+ logger,
+ });
+
+ expect(objects).toEqual([foo2]);
+ expect(excludedObjects).toEqual(
+ [foo1, foo3].map((obj) => toExcludedObject(obj, 'predicate_error'))
+ );
+
+ expect(logger.error).toHaveBeenCalledTimes(2);
+ const logMessages = logger.error.mock.calls.map((call) => call[0]);
+
+ expect(
+ (logMessages[0] as string).startsWith(
+ `Error invoking "isExportable" for object foo:1. Error was: Error: reason`
+ )
+ ).toBe(true);
+ expect(
+ (logMessages[1] as string).startsWith(
+ `Error invoking "isExportable" for object foo:3. Error was: Error: reason`
+ )
+ ).toBe(true);
+ });
+
+ it('excludes references filtered by the `isExportable` predicate', async () => {
+ const foo1 = createObject({
+ type: 'foo',
+ id: '1',
+ references: [
+ {
+ type: 'bar',
+ id: '2',
+ name: 'bar-2',
+ },
+ {
+ type: 'excluded',
+ id: '1',
+ name: 'excluded-1',
+ },
+ ],
+ });
+ const bar2 = createObject({
+ type: 'bar',
+ id: '2',
+ });
+ const excluded1 = createObject({
+ type: 'excluded',
+ id: '1',
+ });
+
+ registerType('foo');
+ registerType('bar');
+ registerType('excluded', { isExportable: () => false });
+
+ savedObjectsClient.bulkGet.mockResolvedValueOnce({
+ saved_objects: [bar2, excluded1],
+ });
+
+ const { objects, excludedObjects } = await collectExportedObjects({
+ objects: [foo1],
+ savedObjectsClient,
+ request,
+ typeRegistry,
+ includeReferences: true,
+ logger,
+ });
+
+ expect(objects).toEqual([foo1, bar2]);
+ expect(excludedObjects).toEqual([excluded1].map((obj) => toExcludedObject(obj)));
+ });
+
+ it('excludes additional objects filtered by the `isExportable` predicate', async () => {
+ const foo1 = createObject({
+ type: 'foo',
+ id: '1',
+ });
+ const bar2 = createObject({
+ type: 'bar',
+ id: '2',
+ });
+ const excluded1 = createObject({
+ type: 'excluded',
+ id: '1',
+ });
+
+ registerType('foo');
+ registerType('bar');
+ registerType('excluded', { isExportable: () => false });
+
+ applyExportTransformsMock.mockImplementationOnce(({ objects }) => [
+ ...objects,
+ bar2,
+ excluded1,
+ ]);
+
+ const { objects, excludedObjects } = await collectExportedObjects({
+ objects: [foo1],
+ savedObjectsClient,
+ request,
+ typeRegistry,
+ includeReferences: true,
+ logger,
+ });
+
+ expect(objects).toEqual([foo1, bar2]);
+ expect(excludedObjects).toEqual([excluded1].map((obj) => toExcludedObject(obj)));
+ });
+
it('returns the missing references', async () => {
const foo1 = createObject({
type: 'foo',
@@ -163,8 +449,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(missingRefs).toEqual([missing1, missing2].map(toIdTuple));
@@ -185,8 +472,9 @@ describe('collectExportedObjects', () => {
objects: [obj1, obj2],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(missingRefs).toHaveLength(0);
@@ -228,8 +516,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
@@ -241,12 +530,12 @@ describe('collectExportedObjects', () => {
expect(applyExportTransformsMock).toHaveBeenCalledTimes(2);
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [foo1],
- transforms: {},
+ transforms: toMap({}),
request,
});
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [bar2],
- transforms: {},
+ transforms: toMap({}),
request,
});
});
@@ -302,8 +591,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2);
@@ -366,8 +656,9 @@ describe('collectExportedObjects', () => {
objects: [foo1, bar2],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
@@ -411,8 +702,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
@@ -474,8 +766,9 @@ describe('collectExportedObjects', () => {
objects: [foo1],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: true,
+ logger,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2);
@@ -490,6 +783,67 @@ describe('collectExportedObjects', () => {
expect.any(Object)
);
});
+
+ it('excludes references filtered by the `isExportable` predicate for additional objects returned by the export transform', async () => {
+ const foo1 = createObject({
+ type: 'foo',
+ id: '1',
+ });
+ const bar2 = createObject({
+ type: 'bar',
+ id: '2',
+ references: [
+ {
+ type: 'dolly',
+ id: '3',
+ name: 'dolly-3',
+ },
+ {
+ type: 'baz',
+ id: '4',
+ name: 'baz-4',
+ },
+ ],
+ });
+ const dolly3 = createObject({
+ type: 'dolly',
+ id: '3',
+ references: [
+ {
+ type: 'baz',
+ id: '4',
+ name: 'baz-4',
+ },
+ ],
+ });
+ const baz4 = createObject({
+ type: 'baz',
+ id: '4',
+ });
+
+ registerType('foo');
+ registerType('bar');
+ registerType('dolly');
+ registerType('baz', { isExportable: () => false });
+
+ applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, bar2]);
+
+ savedObjectsClient.bulkGet.mockResolvedValueOnce({
+ saved_objects: [dolly3, baz4],
+ });
+
+ const { objects, excludedObjects } = await collectExportedObjects({
+ objects: [foo1],
+ savedObjectsClient,
+ request,
+ typeRegistry,
+ includeReferences: true,
+ logger,
+ });
+
+ expect(objects).toEqual([foo1, bar2, dolly3]);
+ expect(excludedObjects).toEqual([baz4].map((obj) => toExcludedObject(obj)));
+ });
});
describe('when `includeReferences` is `false`', () => {
@@ -510,8 +864,9 @@ describe('collectExportedObjects', () => {
objects: [obj1],
savedObjectsClient,
request,
- exportTransforms: {},
+ typeRegistry,
includeReferences: false,
+ logger,
});
expect(missingRefs).toHaveLength(0);
diff --git a/src/core/server/saved_objects/export/collect_exported_objects.ts b/src/core/server/saved_objects/export/collect_exported_objects.ts
index d45782a83c284..4789fd3bff67f 100644
--- a/src/core/server/saved_objects/export/collect_exported_objects.ts
+++ b/src/core/server/saved_objects/export/collect_exported_objects.ts
@@ -8,7 +8,9 @@
import type { SavedObject } from '../../../types';
import type { KibanaRequest } from '../../http';
-import { SavedObjectsClientContract } from '../types';
+import type { Logger } from '../../logging';
+import { SavedObjectsClientContract, SavedObjectsExportablePredicate } from '../types';
+import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry';
import type { SavedObjectsExportTransform } from './types';
import { applyExportTransforms } from './apply_export_transforms';
@@ -22,41 +24,80 @@ interface CollectExportedObjectOptions {
/** The http request initiating the export. */
request: KibanaRequest;
/** export transform per type */
- exportTransforms: Record;
+ typeRegistry: ISavedObjectTypeRegistry;
+ /** logger to use to log potential errors */
+ logger: Logger;
}
interface CollectExportedObjectResult {
objects: SavedObject[];
+ excludedObjects: ExcludedObject[];
missingRefs: CollectedReference[];
}
+interface ExcludedObject {
+ id: string;
+ type: string;
+ reason: ExclusionReason;
+}
+
+export type ExclusionReason = 'predicate_error' | 'excluded';
+
export const collectExportedObjects = async ({
objects,
includeReferences = true,
namespace,
request,
- exportTransforms,
+ typeRegistry,
savedObjectsClient,
+ logger,
}: CollectExportedObjectOptions): Promise => {
+ const exportTransforms = buildTransforms(typeRegistry);
+ const isExportable = buildIsExportable(typeRegistry);
+
const collectedObjects: SavedObject[] = [];
const collectedMissingRefs: CollectedReference[] = [];
+ const collectedNonExportableObjects: ExcludedObject[] = [];
const alreadyProcessed: Set = new Set();
let currentObjects = objects;
do {
- const transformed = (
+ currentObjects = currentObjects.filter((object) => !alreadyProcessed.has(objKey(object)));
+
+ // first, evict current objects that are not exportable
+ const {
+ exportable: untransformedExportableInitialObjects,
+ nonExportable: nonExportableInitialObjects,
+ } = await splitByExportability(currentObjects, isExportable, logger);
+ collectedNonExportableObjects.push(...nonExportableInitialObjects);
+ nonExportableInitialObjects.forEach((obj) => alreadyProcessed.add(objKey(obj)));
+
+ // second, apply export transforms to exportable objects
+ const transformedObjects = (
await applyExportTransforms({
request,
- objects: currentObjects,
+ objects: untransformedExportableInitialObjects,
transforms: exportTransforms,
})
).filter((object) => !alreadyProcessed.has(objKey(object)));
+ transformedObjects.forEach((obj) => alreadyProcessed.add(objKey(obj)));
- transformed.forEach((obj) => alreadyProcessed.add(objKey(obj)));
- collectedObjects.push(...transformed);
+ // last, evict additional objects that are not exportable
+ const { included: exportableInitialObjects, excluded: additionalObjects } = splitByKeys(
+ transformedObjects,
+ untransformedExportableInitialObjects.map((obj) => objKey(obj))
+ );
+ const {
+ exportable: exportableAdditionalObjects,
+ nonExportable: nonExportableAdditionalObjects,
+ } = await splitByExportability(additionalObjects, isExportable, logger);
+ const allExportableObjects = [...exportableInitialObjects, ...exportableAdditionalObjects];
+ collectedNonExportableObjects.push(...nonExportableAdditionalObjects);
+ collectedObjects.push(...allExportableObjects);
+ // if `includeReferences` is true, recurse on exportable objects' references.
if (includeReferences) {
- const references = collectReferences(transformed, alreadyProcessed);
+ const references = collectReferences(allExportableObjects, alreadyProcessed);
if (references.length) {
const { objects: fetchedObjects, missingRefs } = await fetchReferences({
references,
@@ -75,6 +116,7 @@ export const collectExportedObjects = async ({
return {
objects: collectedObjects,
+ excludedObjects: collectedNonExportableObjects,
missingRefs: collectedMissingRefs,
};
};
@@ -126,3 +168,83 @@ const fetchReferences = async ({
.map((obj) => ({ type: obj.type, id: obj.id })),
};
};
+
+const buildTransforms = (typeRegistry: ISavedObjectTypeRegistry) =>
+ typeRegistry.getAllTypes().reduce((transformMap, type) => {
+ if (type.management?.onExport) {
+ transformMap.set(type.name, type.management.onExport);
+ }
+ return transformMap;
+ }, new Map());
+
+const buildIsExportable = (
+ typeRegistry: ISavedObjectTypeRegistry
+): SavedObjectsExportablePredicate => {
+ const exportablePerType = typeRegistry.getAllTypes().reduce((exportableMap, type) => {
+ if (type.management?.isExportable) {
+ exportableMap.set(type.name, type.management.isExportable);
+ }
+ return exportableMap;
+ }, new Map());
+
+ return (obj: SavedObject) => {
+ const typePredicate = exportablePerType.get(obj.type);
+ return typePredicate ? typePredicate(obj) : true;
+ };
+};
+
+const splitByExportability = (
+ objects: SavedObject[],
+ isExportable: SavedObjectsExportablePredicate,
+ logger: Logger
+) => {
+ const exportableObjects: SavedObject[] = [];
+ const nonExportableObjects: ExcludedObject[] = [];
+
+ objects.forEach((obj) => {
+ try {
+ const exportable = isExportable(obj);
+ if (exportable) {
+ exportableObjects.push(obj);
+ } else {
+ nonExportableObjects.push({
+ id: obj.id,
+ type: obj.type,
+ reason: 'excluded',
+ });
+ }
+ } catch (e) {
+ logger.error(
+ `Error invoking "isExportable" for object ${obj.type}:${obj.id}. Error was: ${
+ e.stack ?? e.message
+ }`
+ );
+ nonExportableObjects.push({
+ id: obj.id,
+ type: obj.type,
+ reason: 'predicate_error',
+ });
+ }
+ });
+
+ return {
+ exportable: exportableObjects,
+ nonExportable: nonExportableObjects,
+ };
+};
+
+const splitByKeys = (objects: SavedObject[], keys: ObjectKey[]) => {
+ const included: SavedObject[] = [];
+ const excluded: SavedObject[] = [];
+ objects.forEach((obj) => {
+ if (keys.includes(objKey(obj))) {
+ included.push(obj);
+ } else {
+ excluded.push(obj);
+ }
+ });
+ return {
+ included,
+ excluded,
+ };
+};
diff --git a/src/core/server/saved_objects/export/index.ts b/src/core/server/saved_objects/export/index.ts
index 4af184e54b49c..d9b48ce431117 100644
--- a/src/core/server/saved_objects/export/index.ts
+++ b/src/core/server/saved_objects/export/index.ts
@@ -13,6 +13,7 @@ export type {
SavedObjectsExportResultDetails,
SavedObjectsExportTransformContext,
SavedObjectsExportTransform,
+ SavedObjectsExportExcludedObject,
} from './types';
export { SavedObjectsExporter } from './saved_objects_exporter';
export type { ISavedObjectsExporter } from './saved_objects_exporter';
diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts
index 6bdb8003de49d..5968c8dabe8a8 100644
--- a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts
+++ b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts
@@ -77,32 +77,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {},
- "id": "1",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {},
- "id": "2",
- "references": Array [
- Object {
- "id": "1",
- "name": "name",
- "type": "index-pattern",
- },
- ],
- "type": "search",
- },
- Object {
- "exportedCount": 2,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@@ -185,6 +187,8 @@ describe('getSortedObjectsForExport()', () => {
expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);
expect(response[response.length - 1]).toMatchInlineSnapshot(`
Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
"exportedCount": 20,
"missingRefCount": 0,
"missingReferences": Array [],
@@ -269,6 +273,8 @@ describe('getSortedObjectsForExport()', () => {
expect(savedObjectsClient.find).toHaveBeenCalledTimes(2);
expect(response[response.length - 1]).toMatchInlineSnapshot(`
Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
"exportedCount": 1500,
"missingRefCount": 0,
"missingReferences": Array [],
@@ -422,32 +428,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {},
- "id": "1",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {},
- "id": "2",
- "references": Array [
- Object {
- "id": "1",
- "name": "name",
- "type": "index-pattern",
- },
- ],
- "type": "search",
- },
- Object {
- "exportedCount": 2,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@@ -579,32 +587,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {},
- "id": "1",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {},
- "id": "2",
- "references": Array [
- Object {
- "id": "1",
- "name": "name",
- "type": "index-pattern",
- },
- ],
- "type": "search",
- },
- Object {
- "exportedCount": 2,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@@ -674,26 +684,28 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {},
- "id": "2",
- "references": Array [
- Object {
- "id": "1",
- "name": "name",
- "type": "index-pattern",
- },
- ],
- "type": "search",
- },
- Object {
- "exportedCount": 1,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 1,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@@ -770,32 +782,34 @@ describe('getSortedObjectsForExport()', () => {
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {},
- "id": "1",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {},
- "id": "2",
- "references": Array [
- Object {
- "id": "1",
- "name": "name",
- "type": "index-pattern",
- },
- ],
- "type": "search",
- },
- Object {
- "exportedCount": 2,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@@ -929,38 +943,40 @@ describe('getSortedObjectsForExport()', () => {
});
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {
- "name": "foo",
- },
- "id": "1",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {
- "name": "bar",
- },
- "id": "2",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {
- "name": "baz",
- },
- "id": "3",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "exportedCount": 3,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {
+ "name": "foo",
+ },
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {
+ "name": "bar",
+ },
+ "id": "2",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {
+ "name": "baz",
+ },
+ "id": "3",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 3,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
});
});
@@ -1003,32 +1019,34 @@ describe('getSortedObjectsForExport()', () => {
});
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {},
- "id": "1",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {},
- "id": "2",
- "references": Array [
- Object {
- "id": "1",
- "name": "name",
- "type": "index-pattern",
- },
- ],
- "type": "search",
- },
- Object {
- "exportedCount": 2,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
@@ -1211,32 +1229,34 @@ describe('getSortedObjectsForExport()', () => {
});
const response = await readStreamToCompletion(exportStream);
expect(response).toMatchInlineSnapshot(`
- Array [
- Object {
- "attributes": Object {},
- "id": "1",
- "references": Array [],
- "type": "index-pattern",
- },
- Object {
- "attributes": Object {},
- "id": "2",
- "references": Array [
- Object {
- "id": "1",
- "name": "name",
- "type": "index-pattern",
- },
- ],
- "type": "search",
- },
- Object {
- "exportedCount": 2,
- "missingRefCount": 0,
- "missingReferences": Array [],
- },
- ]
- `);
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "excludedObjects": Array [],
+ "excludedObjectsCount": 0,
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts
index 9d56bb4872a6d..211dcdc4ee62d 100644
--- a/src/core/server/saved_objects/export/saved_objects_exporter.ts
+++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts
@@ -18,7 +18,6 @@ import {
SavedObjectExportBaseOptions,
SavedObjectsExportByObjectOptions,
SavedObjectsExportByTypeOptions,
- SavedObjectsExportTransform,
} from './types';
import { SavedObjectsExportError } from './errors';
import { collectExportedObjects } from './collect_exported_objects';
@@ -34,8 +33,8 @@ export type ISavedObjectsExporter = PublicMethodsOf;
*/
export class SavedObjectsExporter {
readonly #savedObjectsClient: SavedObjectsClientContract;
- readonly #exportTransforms: Record;
readonly #exportSizeLimit: number;
+ readonly #typeRegistry: ISavedObjectTypeRegistry;
readonly #log: Logger;
constructor({
@@ -52,15 +51,7 @@ export class SavedObjectsExporter {
this.#log = logger;
this.#savedObjectsClient = savedObjectsClient;
this.#exportSizeLimit = exportSizeLimit;
- this.#exportTransforms = typeRegistry.getAllTypes().reduce((transforms, type) => {
- if (type.management?.onExport) {
- return {
- ...transforms,
- [type.name]: type.management.onExport,
- };
- }
- return transforms;
- }, {} as Record);
+ this.#typeRegistry = typeRegistry;
}
/**
@@ -121,13 +112,15 @@ export class SavedObjectsExporter {
const {
objects: collectedObjects,
missingRefs: missingReferences,
+ excludedObjects,
} = await collectExportedObjects({
objects: savedObjects,
includeReferences: includeReferencesDeep,
namespace,
request,
- exportTransforms: this.#exportTransforms,
+ typeRegistry: this.#typeRegistry,
savedObjectsClient: this.#savedObjectsClient,
+ logger: this.#log,
});
// sort with the provided sort function then with the default export sorting
@@ -142,6 +135,8 @@ export class SavedObjectsExporter {
exportedCount: exportedObjects.length,
missingRefCount: missingReferences.length,
missingReferences,
+ excludedObjectsCount: excludedObjects.length,
+ excludedObjects,
};
this.#log.debug(`Exporting [${redactedObjects.length}] saved objects.`);
return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]);
diff --git a/src/core/server/saved_objects/export/types.ts b/src/core/server/saved_objects/export/types.ts
index 7891af6df5b1b..a805ec3a06c1b 100644
--- a/src/core/server/saved_objects/export/types.ts
+++ b/src/core/server/saved_objects/export/types.ts
@@ -72,6 +72,20 @@ export interface SavedObjectsExportResultDetails {
/** the missing reference type. */
type: string;
}>;
+ /** number of objects that were excluded from the export */
+ excludedObjectsCount: number;
+ /** excluded objects details */
+ excludedObjects: SavedObjectsExportExcludedObject[];
+}
+
+/** @public */
+export interface SavedObjectsExportExcludedObject {
+ /** id of the excluded object */
+ id: string;
+ /** type of the excluded object */
+ type: string;
+ /** optional cause of the exclusion */
+ reason?: string;
}
/**
@@ -158,7 +172,7 @@ export interface SavedObjectsExportTransformContext {
*
* @public
*/
-export type SavedObjectsExportTransform = (
+export type SavedObjectsExportTransform = (
context: SavedObjectsExportTransformContext,
objects: Array>
) => SavedObject[] | Promise;
diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts
index b1b1584d7de88..5f853d49219dc 100644
--- a/src/core/server/saved_objects/index.ts
+++ b/src/core/server/saved_objects/index.ts
@@ -41,6 +41,7 @@ export type {
SavedObjectsExportError,
SavedObjectsExportTransformContext,
SavedObjectsExportTransform,
+ SavedObjectsExportExcludedObject,
} from './export';
export { SavedObjectsSerializer } from './serialization';
diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts
index 79f5bd09889db..87b8ee0809064 100644
--- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts
+++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts
@@ -748,7 +748,8 @@ describe('DocumentMigrator', () => {
migrator.migrate(_.cloneDeep(failedDoc));
expect('Did not throw').toEqual('But it should have!');
} catch (error) {
- expect(error.message).toBe('Dang diggity!');
+ expect(error.message).toEqual('Migration function for version 1.2.3 threw an error');
+ expect(error.stack.includes(`Caused by:\nError: Dang diggity!`)).toBe(true);
expect(error).toBeInstanceOf(TransformSavedObjectDocumentError);
}
});
diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts
index a32cc999c5559..de8adc23996fd 100644
--- a/src/core/server/saved_objects/migrations/core/document_migrator.ts
+++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts
@@ -260,6 +260,7 @@ function validateMigrationsMapObject(
throw new Error(`${prefix} Got ${obj}.`);
}
}
+
function assertValidSemver(version: string, type: string) {
if (!Semver.valid(version)) {
throw new Error(
@@ -272,6 +273,7 @@ function validateMigrationsMapObject(
);
}
}
+
function assertValidTransform(fn: any, version: string, type: string) {
if (typeof fn !== 'function') {
throw new Error(`Invalid migration ${type}.${version}: expected a function, but got ${fn}.`);
@@ -680,7 +682,7 @@ function wrapWithTry(
return { transformedDoc: result, additionalDocs: [] };
} catch (error) {
log.error(error);
- throw new TransformSavedObjectDocumentError(error);
+ throw new TransformSavedObjectDocumentError(error, version);
}
};
}
diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts
index 7a6f72a881cd6..0481e6118acb0 100644
--- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts
+++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts
@@ -233,7 +233,7 @@ describe('migrateRawDocsSafely', () => {
test('instance of Either.left containing transform errors when the transform function throws a TransformSavedObjectDocument error', async () => {
const transform = jest.fn((doc: any) => {
- throw new TransformSavedObjectDocumentError(new Error('error during transform'));
+ throw new TransformSavedObjectDocumentError(new Error('error during transform'), '8.0.0');
});
const task = migrateRawDocsSafely(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
@@ -247,7 +247,7 @@ describe('migrateRawDocsSafely', () => {
expect(result.left.transformErrors.length).toEqual(1);
expect(result.left.transformErrors[0]).toMatchInlineSnapshot(`
Object {
- "err": [Error: error during transform],
+ "err": [Error: Migration function for version 8.0.0 threw an error],
"rawId": "a:b",
}
`);
diff --git a/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.test.ts b/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.test.ts
index 1efb1bd726216..66ee385b44f46 100644
--- a/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.test.ts
+++ b/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.test.ts
@@ -10,10 +10,38 @@ import { TransformSavedObjectDocumentError } from './transform_saved_object_docu
describe('TransformSavedObjectDocumentError', () => {
it('is a special error', () => {
const originalError = new Error('Dang diggity!');
- const err = new TransformSavedObjectDocumentError(originalError);
+ const err = new TransformSavedObjectDocumentError(originalError, '8.0.0');
+
expect(err).toBeInstanceOf(TransformSavedObjectDocumentError);
expect(err.stack).not.toBeNull();
expect(err.originalError).toBe(originalError);
- expect(err.message).toMatchInlineSnapshot(`"Dang diggity!"`);
+ expect(err.message).toEqual(`Migration function for version 8.0.0 threw an error`);
+ });
+
+ it('adds the stack from the original error', () => {
+ const originalError = new Error('Some went wrong');
+ originalError.stack = 'some stack trace';
+
+ const err = new TransformSavedObjectDocumentError(originalError, '8.0.0');
+ const stackLines = err.stack!.split('\n');
+ const stackLength = stackLines.length;
+
+ expect(stackLength).toBeGreaterThan(3);
+ expect(stackLines[0]).toEqual(`Error: Migration function for version 8.0.0 threw an error`);
+ expect(stackLines[stackLength - 2]).toEqual(`Caused by:`);
+ expect(stackLines[stackLength - 1]).toEqual(`some stack trace`);
+ });
+
+ it('uses the message if the original error does not have a stack', () => {
+ const originalError = new Error('Some went wrong');
+ delete originalError.stack;
+
+ const err = new TransformSavedObjectDocumentError(originalError, '8.0.0');
+ const stackLines = err.stack!.split('\n');
+ const stackLength = stackLines.length;
+
+ expect(stackLength).toBeGreaterThan(3);
+ expect(stackLines[stackLength - 2]).toEqual(`Caused by:`);
+ expect(stackLines[stackLength - 1]).toEqual(`Some went wrong`);
});
});
diff --git a/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.ts b/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.ts
index 2dc553545a08d..11ad643687d85 100644
--- a/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.ts
+++ b/src/core/server/saved_objects/migrations/core/transform_saved_object_document_error.ts
@@ -10,9 +10,13 @@
* Error thrown when saved object migrations encounter a transformation error.
* Transformation errors happen when a transform function throws an error for an unsanitized saved object
*/
-
export class TransformSavedObjectDocumentError extends Error {
- constructor(public readonly originalError: Error) {
- super(`${originalError.message}`);
+ constructor(public readonly originalError: Error, public readonly version: string) {
+ super(`Migration function for version ${version} threw an error`);
+ appendCauseStack(this, originalError);
}
}
+
+const appendCauseStack = (error: Error, cause: Error) => {
+ error.stack = (error.stack ?? '') + `\nCaused by:\n${cause.stack ?? cause.message}`;
+};
diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_document_migration_failure.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_document_migration_failure.zip
new file mode 100644
index 0000000000000..9dc4de75c5d98
Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/8.0.0_document_migration_failure.zip differ
diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts
index 83d97555a4798..3bbdc27e1dd2f 100644
--- a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts
+++ b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts
@@ -17,6 +17,7 @@ const logFilePath = Path.join(__dirname, 'cleanup_test.log');
const asyncUnlink = Util.promisify(Fs.unlink);
const asyncReadFile = Util.promisify(Fs.readFile);
+
async function removeLogFile() {
// ignore errors if it doesn't exist
await asyncUnlink(logFilePath).catch(() => void 0);
@@ -99,9 +100,10 @@ describe('migration v2', () => {
esServer = await startES();
await root.setup();
- await expect(root.start()).rejects.toThrow(
- 'Unable to complete saved object migrations for the [.kibana] index: Migrations failed. Reason: Corrupt saved object documents: index-pattern:test_index*. To allow migrations to proceed, please delete these documents.'
- );
+ await expect(root.start()).rejects.toThrowErrorMatchingInlineSnapshot(`
+ "Unable to complete saved object migrations for the [.kibana] index: Migrations failed. Reason: 1 corrupt saved object documents were found: index-pattern:test_index*
+ To allow migrations to proceed, please delete or fix these documents."
+ `);
const logFileContent = await asyncReadFile(logFilePath, 'utf-8');
const records = logFileContent
diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts
index 9a09fb47d0609..7561536b1ed4b 100644
--- a/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts
+++ b/src/core/server/saved_objects/migrationsv2/integration_tests/corrupt_outdated_docs.test.ts
@@ -15,6 +15,7 @@ import { Root } from '../../../root';
const logFilePath = Path.join(__dirname, 'migration_test_corrupt_docs_kibana.log');
const asyncUnlink = Util.promisify(Fs.unlink);
+
async function removeLogFile() {
// ignore errors if it doesn't exist
await asyncUnlink(logFilePath).catch(() => void 0);
@@ -110,11 +111,13 @@ describe('migration v2 with corrupt saved object documents', () => {
const errorMessage = err.message;
expect(
errorMessage.startsWith(
- 'Unable to complete saved object migrations for the [.kibana] index: Migrations failed. Reason: Corrupt saved object documents: '
+ 'Unable to complete saved object migrations for the [.kibana] index: Migrations failed. Reason: 19 corrupt saved object documents were found: '
)
).toBeTruthy();
expect(
- errorMessage.endsWith(' To allow migrations to proceed, please delete these documents.')
+ errorMessage.endsWith(
+ 'To allow migrations to proceed, please delete or fix these documents.'
+ )
).toBeTruthy();
const expectedCorruptDocIds = [
'"foo:my_name"',
diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_transform_failures.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_transform_failures.test.ts
index c014f7de395e0..73c7016d32c56 100644
--- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_transform_failures.test.ts
+++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7_13_0_transform_failures.test.ts
@@ -15,6 +15,7 @@ import { Root } from '../../../root';
const logFilePath = Path.join(__dirname, '7_13_corrupt_transform_failures_test.log');
const asyncUnlink = Util.promisify(Fs.unlink);
+
async function removeLogFile() {
// ignore errors if it doesn't exist
await asyncUnlink(logFilePath).catch(() => void 0);
@@ -98,11 +99,13 @@ describe('migration v2', () => {
const errorMessage = err.message;
expect(
errorMessage.startsWith(
- 'Unable to complete saved object migrations for the [.kibana] index: Migrations failed. Reason: Corrupt saved object documents: '
+ 'Unable to complete saved object migrations for the [.kibana] index: Migrations failed. Reason: 7 corrupt saved object documents were found: '
)
).toBeTruthy();
expect(
- errorMessage.endsWith(' To allow migrations to proceed, please delete these documents.')
+ errorMessage.endsWith(
+ 'To allow migrations to proceed, please delete or fix these documents.'
+ )
).toBeTruthy();
const expectedCorruptDocIds = [
@@ -117,9 +120,13 @@ describe('migration v2', () => {
for (const corruptDocId of expectedCorruptDocIds) {
expect(errorMessage.includes(corruptDocId)).toBeTruthy();
}
- const expectedTransformErrorMessage =
- 'Transformation errors: space:default: Document "default" has property "space" which belongs to a more recent version of Kibana [6.6.0]. The last known version is [undefined]';
- expect(errorMessage.includes(expectedTransformErrorMessage)).toBeTruthy();
+
+ expect(errorMessage.includes('7 transformation errors were encountered:')).toBeTruthy();
+ expect(
+ errorMessage.includes(
+ 'space:default: Error: Document "default" has property "space" which belongs to a more recent version of Kibana [6.6.0]. The last known version is [undefined]'
+ )
+ ).toBeTruthy();
}
});
});
diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/type_migration_failure.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/type_migration_failure.test.ts
new file mode 100644
index 0000000000000..ac40933d2a7de
--- /dev/null
+++ b/src/core/server/saved_objects/migrationsv2/integration_tests/type_migration_failure.test.ts
@@ -0,0 +1,201 @@
+/*
+ * 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 Path from 'path';
+import Fs from 'fs';
+import Util from 'util';
+import * as kbnTestServer from '../../../../test_helpers/kbn_server';
+import { Root } from '../../../root';
+
+const logFilePath = Path.join(__dirname, 'migration_test_corrupt_docs_kibana.log');
+
+const asyncUnlink = Util.promisify(Fs.unlink);
+
+async function removeLogFile() {
+ // ignore errors if it doesn't exist
+ await asyncUnlink(logFilePath).catch(() => void 0);
+}
+
+describe('migration v2 with corrupt saved object documents', () => {
+ let esServer: kbnTestServer.TestElasticsearchUtils;
+ let root: Root;
+
+ beforeAll(async () => {
+ await removeLogFile();
+ });
+
+ afterAll(async () => {
+ if (root) {
+ await root.shutdown();
+ }
+ if (esServer) {
+ await esServer.stop();
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 10000));
+ });
+
+ it('collects corrupt saved object documents accross batches', async () => {
+ const { startES } = kbnTestServer.createTestServers({
+ adjustTimeout: (t: number) => jest.setTimeout(t),
+ settings: {
+ es: {
+ license: 'basic',
+ // contains 4 `foo` objects, all with a `migrationVersion` of `7.13.0`
+ // - foo:1 and foo:2 have correct values for their `number` property (13 and 42 respectively)
+ // - foo:3 and foo:4 don't have the property, and will fail during the `7.14.0` registered migration
+ // contains migrated index with 8.0 aliases to skip migration, but run outdated doc search
+ dataArchive: Path.join(__dirname, 'archives', '8.0.0_document_migration_failure.zip'),
+ },
+ },
+ });
+
+ root = createRoot();
+
+ esServer = await startES();
+ const coreSetup = await root.setup();
+
+ coreSetup.savedObjects.registerType({
+ name: 'foo',
+ hidden: false,
+ mappings: {
+ properties: {
+ number: { type: 'integer' },
+ },
+ },
+ namespaceType: 'agnostic',
+ migrations: {
+ '7.14.0': (doc) => {
+ if (doc.attributes.number === undefined) {
+ throw new Error('"number" attribute should be present');
+ }
+ doc.attributes = {
+ ...doc.attributes,
+ number: doc.attributes.number + 9000,
+ };
+ return doc;
+ },
+ },
+ });
+
+ try {
+ await root.start();
+ expect(true).toEqual(false);
+ } catch (err) {
+ const errorMessage = err.message;
+ const errorLines = errorMessage.split('\n');
+
+ expect(errorLines[0]).toEqual(
+ `Unable to complete saved object migrations for the [.kibana] index: Migrations failed. Reason: 2 transformation errors were encountered:`
+ );
+ expect(errorLines[errorLines.length - 1]).toEqual(
+ `To allow migrations to proceed, please delete or fix these documents.`
+ );
+
+ expectMatchOrder(errorLines, [
+ {
+ mode: 'equal',
+ value: '- foo:3: Error: Migration function for version 7.14.0 threw an error',
+ },
+ {
+ mode: 'contain',
+ value: 'at transform',
+ },
+ {
+ mode: 'equal',
+ value: 'Caused by:',
+ },
+ {
+ mode: 'equal',
+ value: 'Error: "number" attribute should be present',
+ },
+ {
+ mode: 'contain',
+ value: 'at migrationFn',
+ },
+ {
+ mode: 'equal',
+ value: '- foo:4: Error: Migration function for version 7.14.0 threw an error',
+ },
+ {
+ mode: 'contain',
+ value: 'at transform',
+ },
+ {
+ mode: 'equal',
+ value: 'Caused by:',
+ },
+ {
+ mode: 'equal',
+ value: 'Error: "number" attribute should be present',
+ },
+ {
+ mode: 'contain',
+ value: 'at migrationFn',
+ },
+ ]);
+ }
+ });
+});
+
+function createRoot() {
+ return kbnTestServer.createRootWithCorePlugins(
+ {
+ migrations: {
+ skip: false,
+ enableV2: true,
+ batchSize: 5,
+ },
+ logging: {
+ appenders: {
+ file: {
+ type: 'file',
+ fileName: logFilePath,
+ layout: {
+ type: 'json',
+ },
+ },
+ },
+ loggers: [
+ {
+ name: 'root',
+ appenders: ['file'],
+ },
+ ],
+ },
+ },
+ {
+ oss: true,
+ }
+ );
+}
+
+type FindInOrderPattern = { mode: 'equal'; value: string } | { mode: 'contain'; value: string };
+
+const expectMatchOrder = (lines: string[], patterns: FindInOrderPattern[]) => {
+ let lineIdx = 0;
+ let patternIdx = 0;
+
+ while (lineIdx < lines.length && patternIdx < patterns.length) {
+ const line = lines[lineIdx];
+ const pattern = patterns[patternIdx];
+ if (lineMatch(line, pattern)) {
+ patternIdx++;
+ }
+ lineIdx++;
+ }
+
+ expect(patternIdx).toEqual(patterns.length);
+};
+
+const lineMatch = (line: string, pattern: FindInOrderPattern) => {
+ if (pattern.mode === 'contain') {
+ return line.trim().includes(pattern.value.trim());
+ }
+ return line.trim() === pattern.value.trim();
+};
diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts
index 86dc590aabdad..ea8bc7f110735 100644
--- a/src/core/server/saved_objects/migrationsv2/model.test.ts
+++ b/src/core/server/saved_objects/migrationsv2/model.test.ts
@@ -863,9 +863,10 @@ describe('migrations v2 model', () => {
});
const newState = model(testState, res) as FatalState;
expect(newState.controlState).toBe('FATAL');
- expect(newState.reason).toMatchInlineSnapshot(
- `"Migrations failed. Reason: Corrupt saved object documents: a:b. To allow migrations to proceed, please delete these documents."`
- );
+ expect(newState.reason).toMatchInlineSnapshot(`
+ "Migrations failed. Reason: 1 corrupt saved object documents were found: a:b
+ To allow migrations to proceed, please delete or fix these documents."
+ `);
expect(newState.logs).toStrictEqual([]); // No logs because no hits
});
});
@@ -1158,7 +1159,10 @@ describe('migrations v2 model', () => {
it('OUTDATED_DOCUMENTS_SEARCH_READ -> FATAL if no outdated documents to transform and we have failed document migrations', () => {
const corruptDocumentIdsCarriedOver = ['a:somethingelse'];
const originalTransformError = new Error('something went wrong');
- const transFormErr = new TransformSavedObjectDocumentError(originalTransformError);
+ const transFormErr = new TransformSavedObjectDocumentError(
+ originalTransformError,
+ '7.11.0'
+ );
const transformationErrors = [
{ rawId: 'bob:tail', err: transFormErr },
] as TransformErrorObjects[];
@@ -1175,8 +1179,8 @@ describe('migrations v2 model', () => {
const newState = model(transformErrorsState, res) as FatalState;
expect(newState.controlState).toBe('FATAL');
expect(newState.reason.includes('Migrations failed. Reason:')).toBe(true);
- expect(newState.reason.includes('Corrupt saved object documents: ')).toBe(true);
- expect(newState.reason.includes('Transformation errors: ')).toBe(true);
+ expect(newState.reason.includes('1 corrupt saved object documents were found')).toBe(true);
+ expect(newState.reason.includes('1 transformation errors were encountered')).toBe(true);
expect(newState.reason.includes('bob:tail')).toBe(true);
expect(newState.logs).toStrictEqual([]); // No logs because no hits
});
@@ -1222,7 +1226,7 @@ describe('migrations v2 model', () => {
const outdatedDocuments = [{ _id: '1', _source: { type: 'vis' } }];
const corruptDocumentIds = ['a:somethingelse'];
const originalTransformError = new Error('Dang diggity!');
- const transFormErr = new TransformSavedObjectDocumentError(originalTransformError);
+ const transFormErr = new TransformSavedObjectDocumentError(originalTransformError, '7.11.0');
const transformationErrors = [
{ rawId: 'bob:tail', err: transFormErr },
] as TransformErrorObjects[];
diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts
index 252d7424c339c..6aa119af2f6c8 100644
--- a/src/core/server/saved_objects/migrationsv2/model.ts
+++ b/src/core/server/saved_objects/migrationsv2/model.ts
@@ -112,17 +112,22 @@ function extractTransformFailuresReason(
): string {
const corruptDocumentIdReason =
corruptDocumentIds.length > 0
- ? ` Corrupt saved object documents: ${corruptDocumentIds.join(',')}`
+ ? ` ${
+ corruptDocumentIds.length
+ } corrupt saved object documents were found: ${corruptDocumentIds.join(',')}`
: '';
// we have both the saved object Id and the stack trace in each `transformErrors` item.
const transformErrorsReason =
transformErrors.length > 0
- ? ' Transformation errors: ' +
+ ? ` ${transformErrors.length} transformation errors were encountered:\n ` +
transformErrors
- .map((errObj) => `${errObj.rawId}: ${errObj.err.message}\n ${errObj.err.stack ?? ''}`)
- .join('/n')
+ .map((errObj) => `- ${errObj.rawId}: ${errObj.err.stack ?? errObj.err.message}\n`)
+ .join('')
: '';
- return `Migrations failed. Reason:${corruptDocumentIdReason}${transformErrorsReason}. To allow migrations to proceed, please delete these documents.`;
+ return (
+ `Migrations failed. Reason:${corruptDocumentIdReason}${transformErrorsReason}\n` +
+ `To allow migrations to proceed, please delete or fix these documents.`
+ );
}
const delayRetryState = (
diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts
index b95f187cd44ca..e50c1e540bfaf 100644
--- a/src/core/server/saved_objects/saved_objects_service.ts
+++ b/src/core/server/saved_objects/saved_objects_service.ts
@@ -141,7 +141,7 @@ export interface SavedObjectsServiceSetup {
* }
* ```
*/
- registerType: (type: SavedObjectsType) => void;
+ registerType: (type: SavedObjectsType) => void;
}
/**
diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts
index 964ba671b5964..1bb214de701e2 100644
--- a/src/core/server/saved_objects/types.ts
+++ b/src/core/server/saved_objects/types.ts
@@ -253,7 +253,7 @@ export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolat
*
* @public
*/
-export interface SavedObjectsType {
+export interface SavedObjectsType {
/**
* The name of the type, which is also used as the internal id.
*/
@@ -337,7 +337,7 @@ export interface SavedObjectsType {
/**
* An optional {@link SavedObjectsTypeManagementDefinition | saved objects management section} definition for the type.
*/
- management?: SavedObjectsTypeManagementDefinition;
+ management?: SavedObjectsTypeManagementDefinition;
}
/**
@@ -345,7 +345,7 @@ export interface SavedObjectsType {
*
* @public
*/
-export interface SavedObjectsTypeManagementDefinition {
+export interface SavedObjectsTypeManagementDefinition {
/**
* Is the type importable or exportable. Defaults to `false`.
*/
@@ -363,12 +363,12 @@ export interface SavedObjectsTypeManagementDefinition {
* Function returning the title to display in the management table.
* If not defined, will use the object's type and id to generate a label.
*/
- getTitle?: (savedObject: SavedObject) => string;
+ getTitle?: (savedObject: SavedObject) => string;
/**
* Function returning the url to use to redirect to the editing page of this object.
* If not defined, editing will not be allowed.
*/
- getEditUrl?: (savedObject: SavedObject) => string;
+ getEditUrl?: (savedObject: SavedObject) => string;
/**
* Function returning the url to use to redirect to this object from the management section.
* If not defined, redirecting to the object will not be allowed.
@@ -377,7 +377,9 @@ export interface SavedObjectsTypeManagementDefinition {
* the object page, relative to the base path. `uiCapabilitiesPath` is the path to check in the
* {@link Capabilities | uiCapabilities} to check if the user has permission to access the object.
*/
- getInAppUrl?: (savedObject: SavedObject) => { path: string; uiCapabilitiesPath: string };
+ getInAppUrl?: (
+ savedObject: SavedObject
+ ) => { path: string; uiCapabilitiesPath: string };
/**
* An optional export transform function that can be used transform the objects of the registered type during
* the export process.
@@ -386,9 +388,14 @@ export interface SavedObjectsTypeManagementDefinition {
*
* See {@link SavedObjectsExportTransform | the transform type documentation} for more info and examples.
*
+ * When implementing both `isExportable` and `onExport`, it is mandatory that
+ * `isExportable` returns the same value for an object before and after going
+ * though the export transform.
+ * E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
+ *
* @remarks `importableAndExportable` must be `true` to specify this property.
*/
- onExport?: SavedObjectsExportTransform;
+ onExport?: SavedObjectsExportTransform;
/**
* An optional {@link SavedObjectsImportHook | import hook} to use when importing given type.
*
@@ -431,5 +438,52 @@ export interface SavedObjectsTypeManagementDefinition {
* @remarks messages returned in the warnings are user facing and must be translated.
* @remarks `importableAndExportable` must be `true` to specify this property.
*/
- onImport?: SavedObjectsImportHook;
+ onImport?: SavedObjectsImportHook;
+
+ /**
+ * Optional hook to specify whether an object should be exportable.
+ *
+ * If specified, `isExportable` will be called during export for each
+ * of this type's objects in the export, and the ones not matching the
+ * predicate will be excluded from the export.
+ *
+ * When implementing both `isExportable` and `onExport`, it is mandatory that
+ * `isExportable` returns the same value for an object before and after going
+ * though the export transform.
+ * E.g `isExportable(objectBeforeTransform) === isExportable(objectAfterTransform)`
+ *
+ * @example
+ * Registering a type with a per-object exportability predicate
+ * ```ts
+ * // src/plugins/my_plugin/server/plugin.ts
+ * import { myType } from './saved_objects';
+ *
+ * export class Plugin() {
+ * setup: (core: CoreSetup) => {
+ * core.savedObjects.registerType({
+ * ...myType,
+ * management: {
+ * ...myType.management,
+ * isExportable: (object) => {
+ * if (object.attributes.myCustomAttr === 'foo') {
+ * return false;
+ * }
+ * return true;
+ * }
+ * },
+ * });
+ * }
+ * }
+ * ```
+ *
+ * @remarks `importableAndExportable` must be `true` to specify this property.
+ */
+ isExportable?: SavedObjectsExportablePredicate;
}
+
+/**
+ * @public
+ */
+export type SavedObjectsExportablePredicate = (
+ obj: SavedObject
+) => boolean;
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index ce13174ee19cc..9e7721fde90e7 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -2508,8 +2508,17 @@ export class SavedObjectsExportError extends Error {
readonly type: string;
}
+// @public (undocumented)
+export interface SavedObjectsExportExcludedObject {
+ id: string;
+ reason?: string;
+ type: string;
+}
+
// @public
export interface SavedObjectsExportResultDetails {
+ excludedObjects: SavedObjectsExportExcludedObject[];
+ excludedObjectsCount: number;
exportedCount: number;
missingRefCount: number;
missingReferences: Array<{
@@ -2519,7 +2528,7 @@ export interface SavedObjectsExportResultDetails {
}
// @public
-export type SavedObjectsExportTransform = (context: SavedObjectsExportTransformContext, objects: Array>) => SavedObject[] | Promise;
+export type SavedObjectsExportTransform = (context: SavedObjectsExportTransformContext, objects: Array>) => SavedObject[] | Promise;
// @public
export interface SavedObjectsExportTransformContext {
@@ -2930,7 +2939,7 @@ export class SavedObjectsSerializer {
// @public
export interface SavedObjectsServiceSetup {
addClientWrapper: (priority: number, id: string, factory: SavedObjectsClientWrapperFactory) => void;
- registerType: (type: SavedObjectsType) => void;
+ registerType: (type: SavedObjectsType) => void;
setClientFactoryProvider: (clientFactoryProvider: SavedObjectsClientFactoryProvider) => void;
}
@@ -2956,12 +2965,12 @@ export interface SavedObjectStatusMeta {
}
// @public (undocumented)
-export interface SavedObjectsType {
+export interface SavedObjectsType {
convertToAliasScript?: string;
convertToMultiNamespaceTypeVersion?: string;
hidden: boolean;
indexPattern?: string;
- management?: SavedObjectsTypeManagementDefinition;
+ management?: SavedObjectsTypeManagementDefinition;
mappings: SavedObjectsTypeMappingDefinition;
migrations?: SavedObjectMigrationMap | (() => SavedObjectMigrationMap);
name: string;
@@ -2969,18 +2978,20 @@ export interface SavedObjectsType {
}
// @public
-export interface SavedObjectsTypeManagementDefinition {
+export interface SavedObjectsTypeManagementDefinition {
defaultSearchField?: string;
- getEditUrl?: (savedObject: SavedObject) => string;
- getInAppUrl?: (savedObject: SavedObject) => {
+ getEditUrl?: (savedObject: SavedObject) => string;
+ getInAppUrl?: (savedObject: SavedObject) => {
path: string;
uiCapabilitiesPath: string;
};
- getTitle?: (savedObject: SavedObject) => string;
+ getTitle?: (savedObject: SavedObject) => string;
icon?: string;
importableAndExportable?: boolean;
- onExport?: SavedObjectsExportTransform;
- onImport?: SavedObjectsImportHook;
+ // Warning: (ae-forgotten-export) The symbol "SavedObjectsExportablePredicate" needs to be exported by the entry point index.d.ts
+ isExportable?: SavedObjectsExportablePredicate;
+ onExport?: SavedObjectsExportTransform;
+ onImport?: SavedObjectsImportHook;
}
// @public
@@ -3045,11 +3056,11 @@ export class SavedObjectsUtils {
// @public
export class SavedObjectTypeRegistry {
- getAllTypes(): SavedObjectsType[];
- getImportableAndExportableTypes(): SavedObjectsType[];
+ getAllTypes(): SavedObjectsType[];
+ getImportableAndExportableTypes(): SavedObjectsType[];
getIndex(type: string): string | undefined;
- getType(type: string): SavedObjectsType | undefined;
- getVisibleTypes(): SavedObjectsType[];
+ getType(type: string): SavedObjectsType | undefined;
+ getVisibleTypes(): SavedObjectsType[];
isHidden(type: string): boolean;
isImportableAndExportable(type: string): boolean;
isMultiNamespace(type: string): boolean;
diff --git a/src/plugins/saved_objects_management/public/lib/extract_export_details.test.ts b/src/plugins/saved_objects_management/public/lib/extract_export_details.test.ts
index c9f9b8591860d..7487e57da9e8c 100644
--- a/src/plugins/saved_objects_management/public/lib/extract_export_details.test.ts
+++ b/src/plugins/saved_objects_management/public/lib/extract_export_details.test.ts
@@ -14,14 +14,22 @@ describe('extractExportDetails', () => {
};
const detailsLine = (
exported: number,
- missingRefs: SavedObjectsExportResultDetails['missingReferences'] = []
+ {
+ missingRefs = [],
+ excludedObjects = [],
+ }: {
+ missingRefs?: SavedObjectsExportResultDetails['missingReferences'];
+ excludedObjects?: SavedObjectsExportResultDetails['excludedObjects'];
+ } = {}
) => {
return (
JSON.stringify({
exportedCount: exported,
missingRefCount: missingRefs.length,
missingReferences: missingRefs,
- }) + '\n'
+ excludedObjectsCount: excludedObjects.length,
+ excludedObjects,
+ } as SavedObjectsExportResultDetails) + '\n'
);
};
@@ -43,6 +51,8 @@ describe('extractExportDetails', () => {
exportedCount: 3,
missingRefCount: 0,
missingReferences: [],
+ excludedObjectsCount: 0,
+ excludedObjects: [],
});
});
@@ -51,10 +61,12 @@ describe('extractExportDetails', () => {
[
[
objLine('1', 'index-pattern'),
- detailsLine(1, [
- { id: '2', type: 'index-pattern' },
- { id: '3', type: 'index-pattern' },
- ]),
+ detailsLine(1, {
+ missingRefs: [
+ { id: '2', type: 'index-pattern' },
+ { id: '3', type: 'index-pattern' },
+ ],
+ }),
].join(''),
],
{
@@ -71,6 +83,39 @@ describe('extractExportDetails', () => {
{ id: '2', type: 'index-pattern' },
{ id: '3', type: 'index-pattern' },
],
+ excludedObjectsCount: 0,
+ excludedObjects: [],
+ });
+ });
+
+ it('should properly extract the excluded objects', async () => {
+ const exportData = new Blob(
+ [
+ [
+ objLine('1', 'index-pattern'),
+ detailsLine(1, {
+ excludedObjects: [
+ { id: '2', type: 'index-pattern', reason: 'foo' },
+ { id: '3', type: 'index-pattern' },
+ ],
+ }),
+ ].join(''),
+ ],
+ {
+ type: 'application/ndjson',
+ endings: 'transparent',
+ }
+ );
+ const result = await extractExportDetails(exportData);
+ expect(result).toEqual({
+ exportedCount: 1,
+ missingRefCount: 0,
+ missingReferences: [],
+ excludedObjectsCount: 2,
+ excludedObjects: [
+ { id: '2', type: 'index-pattern', reason: 'foo' },
+ { id: '3', type: 'index-pattern' },
+ ],
});
});
diff --git a/src/plugins/saved_objects_management/public/lib/extract_export_details.ts b/src/plugins/saved_objects_management/public/lib/extract_export_details.ts
index 40f8039a8cdae..4d142330dca88 100644
--- a/src/plugins/saved_objects_management/public/lib/extract_export_details.ts
+++ b/src/plugins/saved_objects_management/public/lib/extract_export_details.ts
@@ -33,6 +33,12 @@ export interface SavedObjectsExportResultDetails {
id: string;
type: string;
}>;
+ excludedObjectsCount: number;
+ excludedObjects: Array<{
+ id: string;
+ type: string;
+ reason?: string;
+ }>;
}
function isExportDetails(object: any): object is SavedObjectsExportResultDetails {
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx
index 364b3ab0d9eb6..9b8474fc08bbd 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx
@@ -258,7 +258,7 @@ describe('SavedObjectsTable', () => {
});
});
- it('should display a warning is export contains missing references', async () => {
+ it('should display a warning if the export contains missing references', async () => {
const mockSelectedSavedObjects = [
{ id: '1', type: 'index-pattern' },
{ id: '3', type: 'dashboard' },
@@ -280,6 +280,8 @@ describe('SavedObjectsTable', () => {
exportedCount: 2,
missingRefCount: 1,
missingReferences: [{ id: '7', type: 'visualisation' }],
+ excludedObjectsCount: 0,
+ excludedObjects: [],
}));
const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient });
@@ -303,6 +305,53 @@ describe('SavedObjectsTable', () => {
});
});
+ it('should display a specific message if the export contains excluded objects', async () => {
+ const mockSelectedSavedObjects = [
+ { id: '1', type: 'index-pattern' },
+ { id: '3', type: 'dashboard' },
+ ] as SavedObjectWithMetadata[];
+
+ const mockSavedObjects = mockSelectedSavedObjects.map((obj) => ({
+ _id: obj.id,
+ _source: {},
+ }));
+
+ const mockSavedObjectsClient = {
+ ...defaultProps.savedObjectsClient,
+ bulkGet: jest.fn().mockImplementation(() => ({
+ savedObjects: mockSavedObjects,
+ })),
+ };
+
+ extractExportDetailsMock.mockImplementation(() => ({
+ exportedCount: 2,
+ missingRefCount: 0,
+ missingReferences: [],
+ excludedObjectsCount: 1,
+ excludedObjects: [{ id: '7', type: 'visualisation' }],
+ }));
+
+ const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient });
+
+ // Ensure all promises resolve
+ await new Promise((resolve) => process.nextTick(resolve));
+ // Ensure the state changes are reflected
+ component.update();
+
+ // Set some as selected
+ component.instance().onSelectionChanged(mockSelectedSavedObjects);
+
+ await component.instance().onExport(true);
+
+ expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true);
+ expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({
+ title:
+ 'Your file is downloading in the background. ' +
+ 'Some objects were excluded from the export. ' +
+ 'Please see the last line in the exported file for a list of excluded objects.',
+ });
+ });
+
it('should allow the user to choose when exporting all', async () => {
const component = shallowRender();
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx
index e23c74bc1bc19..42c1220ef5540 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx
@@ -358,7 +358,7 @@ export class SavedObjectsTable extends Component {
@@ -395,31 +395,45 @@ export class SavedObjectsTable extends Component {
+ showExportCompleteMessage = (exportDetails: SavedObjectsExportResultDetails | undefined) => {
const { notifications } = this.props;
- if (exportDetails && exportDetails.missingReferences.length > 0) {
- notifications.toasts.addWarning({
- title: i18n.translate(
- 'savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification',
- {
- defaultMessage:
- 'Your file is downloading in the background. ' +
- 'Some related objects could not be found. ' +
- 'Please see the last line in the exported file for a list of missing objects.',
- }
- ),
- });
- } else {
- notifications.toasts.addSuccess({
- title: i18n.translate('savedObjectsManagement.objectsTable.export.successNotification', {
- defaultMessage: 'Your file is downloading in the background',
- }),
- });
+ if (exportDetails) {
+ if (exportDetails.missingReferences.length > 0) {
+ return notifications.toasts.addWarning({
+ title: i18n.translate(
+ 'savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification',
+ {
+ defaultMessage:
+ 'Your file is downloading in the background. ' +
+ 'Some related objects could not be found. ' +
+ 'Please see the last line in the exported file for a list of missing objects.',
+ }
+ ),
+ });
+ }
+ if (exportDetails.excludedObjects.length > 0) {
+ return notifications.toasts.addSuccess({
+ title: i18n.translate(
+ 'savedObjectsManagement.objectsTable.export.successWithExcludedObjectsNotification',
+ {
+ defaultMessage:
+ 'Your file is downloading in the background. ' +
+ 'Some objects were excluded from the export. ' +
+ 'Please see the last line in the exported file for a list of excluded objects.',
+ }
+ ),
+ });
+ }
}
+ return notifications.toasts.addSuccess({
+ title: i18n.translate('savedObjectsManagement.objectsTable.export.successNotification', {
+ defaultMessage: 'Your file is downloading in the background',
+ }),
+ });
};
finishImport = () => {
diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion/data.json
new file mode 100644
index 0000000000000..f7015ee20251d
--- /dev/null
+++ b/test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion/data.json
@@ -0,0 +1,135 @@
+{
+ "type": "doc",
+ "value": {
+ "index": ".kibana",
+ "type": "doc",
+ "id": "test-is-exportable:1",
+ "source": {
+ "test-is-exportable": {
+ "title": "obj 1",
+ "enabled": true
+ },
+ "type": "test-is-exportable",
+ "migrationVersion": {},
+ "updated_at": "2018-12-21T00:43:07.096Z",
+ "references": [
+ {
+ "type": "test-is-exportable",
+ "id": "2",
+ "name": "ref-1"
+ },
+ {
+ "type": "test-is-exportable",
+ "id": "3",
+ "name": "ref-2"
+ }
+ ]
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "index": ".kibana",
+ "type": "doc",
+ "id": "test-is-exportable:2",
+ "source": {
+ "test-is-exportable": {
+ "title": "obj 2",
+ "enabled": false
+ },
+ "type": "test-is-exportable",
+ "migrationVersion": {},
+ "updated_at": "2018-12-21T00:43:07.096Z",
+ "references": []
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "index": ".kibana",
+ "type": "doc",
+ "id": "test-is-exportable:3",
+ "source": {
+ "test-is-exportable": {
+ "title": "obj 3",
+ "enabled": true
+ },
+ "type": "test-is-exportable",
+ "migrationVersion": {},
+ "updated_at": "2018-12-21T00:43:07.096Z",
+ "references": [
+ {
+ "type": "test-is-exportable",
+ "id": "4",
+ "name": "ref-1"
+ },
+ {
+ "type": "test-is-exportable",
+ "id": "5",
+ "name": "ref-2"
+ }
+ ]
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "index": ".kibana",
+ "type": "doc",
+ "id": "test-is-exportable:4",
+ "source": {
+ "test-is-exportable": {
+ "title": "obj 4",
+ "enabled": false
+ },
+ "type": "test-is-exportable",
+ "migrationVersion": {},
+ "updated_at": "2018-12-21T00:43:07.096Z",
+ "references": []
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "index": ".kibana",
+ "type": "doc",
+ "id": "test-is-exportable:5",
+ "source": {
+ "test-is-exportable": {
+ "title": "obj 5",
+ "enabled": true
+ },
+ "type": "test-is-exportable",
+ "migrationVersion": {},
+ "updated_at": "2018-12-21T00:43:07.096Z",
+ "references": []
+ }
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "index": ".kibana",
+ "type": "doc",
+ "id": "test-is-exportable:error",
+ "source": {
+ "test-is-exportable": {
+ "title": "obj error",
+ "enabled": true
+ },
+ "type": "test-is-exportable",
+ "migrationVersion": {},
+ "updated_at": "2018-12-21T00:43:07.096Z",
+ "references": []
+ }
+ }
+}
diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion/mappings.json
new file mode 100644
index 0000000000000..abec2eeb77492
--- /dev/null
+++ b/test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion/mappings.json
@@ -0,0 +1,505 @@
+{
+ "type": "index",
+ "value": {
+ "index": ".kibana",
+ "settings": {
+ "index": {
+ "number_of_shards": "1",
+ "auto_expand_replicas": "0-1",
+ "number_of_replicas": "0"
+ }
+ },
+ "mappings": {
+ "dynamic": "strict",
+ "properties": {
+ "test-export-transform": {
+ "properties": {
+ "title": { "type": "text" },
+ "enabled": { "type": "boolean" }
+ }
+ },
+ "test-is-exportable": {
+ "properties": {
+ "title": { "type": "text" },
+ "enabled": { "type": "boolean" }
+ }
+ },
+ "test-export-add": {
+ "properties": {
+ "title": { "type": "text" }
+ }
+ },
+ "test-export-add-dep": {
+ "properties": {
+ "title": { "type": "text" }
+ }
+ },
+ "test-export-transform-error": {
+ "properties": {
+ "title": { "type": "text" }
+ }
+ },
+ "test-export-invalid-transform": {
+ "properties": {
+ "title": { "type": "text" }
+ }
+ },
+ "apm-telemetry": {
+ "properties": {
+ "has_any_services": {
+ "type": "boolean"
+ },
+ "services_per_agent": {
+ "properties": {
+ "go": {
+ "type": "long",
+ "null_value": 0
+ },
+ "java": {
+ "type": "long",
+ "null_value": 0
+ },
+ "js-base": {
+ "type": "long",
+ "null_value": 0
+ },
+ "nodejs": {
+ "type": "long",
+ "null_value": 0
+ },
+ "python": {
+ "type": "long",
+ "null_value": 0
+ },
+ "ruby": {
+ "type": "long",
+ "null_value": 0
+ }
+ }
+ }
+ }
+ },
+ "canvas-workpad": {
+ "dynamic": "false",
+ "properties": {
+ "@created": {
+ "type": "date"
+ },
+ "@timestamp": {
+ "type": "date"
+ },
+ "id": {
+ "type": "text",
+ "index": false
+ },
+ "name": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "config": {
+ "dynamic": "true",
+ "properties": {
+ "accessibility:disableAnimations": {
+ "type": "boolean"
+ },
+ "buildNum": {
+ "type": "keyword"
+ },
+ "dateFormat:tz": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 256
+ }
+ }
+ },
+ "defaultIndex": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 256
+ }
+ }
+ },
+ "telemetry:optIn": {
+ "type": "boolean"
+ }
+ }
+ },
+ "dashboard": {
+ "properties": {
+ "description": {
+ "type": "text"
+ },
+ "hits": {
+ "type": "integer"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "optionsJSON": {
+ "type": "text"
+ },
+ "panelsJSON": {
+ "type": "text"
+ },
+ "refreshInterval": {
+ "properties": {
+ "display": {
+ "type": "keyword"
+ },
+ "pause": {
+ "type": "boolean"
+ },
+ "section": {
+ "type": "integer"
+ },
+ "value": {
+ "type": "integer"
+ }
+ }
+ },
+ "timeFrom": {
+ "type": "keyword"
+ },
+ "timeRestore": {
+ "type": "boolean"
+ },
+ "timeTo": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ },
+ "uiStateJSON": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "map": {
+ "properties": {
+ "bounds": {
+ "dynamic": false,
+ "properties": {}
+ },
+ "description": {
+ "type": "text"
+ },
+ "layerListJSON": {
+ "type": "text"
+ },
+ "mapStateJSON": {
+ "type": "text"
+ },
+ "title": {
+ "type": "text"
+ },
+ "uiStateJSON": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "graph-workspace": {
+ "properties": {
+ "description": {
+ "type": "text"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "numLinks": {
+ "type": "integer"
+ },
+ "numVertices": {
+ "type": "integer"
+ },
+ "title": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ },
+ "wsState": {
+ "type": "text"
+ }
+ }
+ },
+ "index-pattern": {
+ "properties": {
+ "fieldFormatMap": {
+ "type": "text"
+ },
+ "fields": {
+ "type": "text"
+ },
+ "intervalName": {
+ "type": "keyword"
+ },
+ "notExpandable": {
+ "type": "boolean"
+ },
+ "sourceFilters": {
+ "type": "text"
+ },
+ "timeFieldName": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ },
+ "type": {
+ "type": "keyword"
+ },
+ "typeMeta": {
+ "type": "keyword"
+ }
+ }
+ },
+ "kql-telemetry": {
+ "properties": {
+ "optInCount": {
+ "type": "long"
+ },
+ "optOutCount": {
+ "type": "long"
+ }
+ }
+ },
+ "migrationVersion": {
+ "dynamic": "true",
+ "properties": {
+ "index-pattern": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 256
+ }
+ }
+ },
+ "space": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 256
+ }
+ }
+ }
+ }
+ },
+ "namespace": {
+ "type": "keyword"
+ },
+ "search": {
+ "properties": {
+ "columns": {
+ "type": "keyword"
+ },
+ "description": {
+ "type": "text"
+ },
+ "hits": {
+ "type": "integer"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "sort": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "server": {
+ "properties": {
+ "uuid": {
+ "type": "keyword"
+ }
+ }
+ },
+ "space": {
+ "properties": {
+ "_reserved": {
+ "type": "boolean"
+ },
+ "color": {
+ "type": "keyword"
+ },
+ "description": {
+ "type": "text"
+ },
+ "disabledFeatures": {
+ "type": "keyword"
+ },
+ "initials": {
+ "type": "keyword"
+ },
+ "name": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 2048
+ }
+ }
+ }
+ }
+ },
+ "spaceId": {
+ "type": "keyword"
+ },
+ "telemetry": {
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "timelion-sheet": {
+ "properties": {
+ "description": {
+ "type": "text"
+ },
+ "hits": {
+ "type": "integer"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "timelion_chart_height": {
+ "type": "integer"
+ },
+ "timelion_columns": {
+ "type": "integer"
+ },
+ "timelion_interval": {
+ "type": "keyword"
+ },
+ "timelion_other_interval": {
+ "type": "keyword"
+ },
+ "timelion_rows": {
+ "type": "integer"
+ },
+ "timelion_sheet": {
+ "type": "text"
+ },
+ "title": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "type": {
+ "type": "keyword"
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "url": {
+ "properties": {
+ "accessCount": {
+ "type": "long"
+ },
+ "accessDate": {
+ "type": "date"
+ },
+ "createDate": {
+ "type": "date"
+ },
+ "url": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 2048
+ }
+ }
+ }
+ }
+ },
+ "visualization": {
+ "properties": {
+ "description": {
+ "type": "text"
+ },
+ "kibanaSavedObjectMeta": {
+ "properties": {
+ "searchSourceJSON": {
+ "type": "text"
+ }
+ }
+ },
+ "savedSearchId": {
+ "type": "keyword"
+ },
+ "title": {
+ "type": "text"
+ },
+ "uiStateJSON": {
+ "type": "text"
+ },
+ "version": {
+ "type": "integer"
+ },
+ "visState": {
+ "type": "text"
+ }
+ }
+ },
+ "references": {
+ "properties": {
+ "id": {
+ "type": "keyword"
+ },
+ "name": {
+ "type": "keyword"
+ },
+ "type": {
+ "type": "keyword"
+ }
+ },
+ "type": "nested"
+ }
+ }
+ }
+ }
+}
diff --git a/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts b/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts
index 408ac03dd946b..15afdb229b1fd 100644
--- a/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts
+++ b/test/plugin_functional/plugins/saved_object_export_transforms/server/plugin.ts
@@ -152,6 +152,30 @@ export class SavedObjectExportTransformsPlugin implements Plugin {
getTitle: (obj) => obj.attributes.title,
},
});
+
+ // example of a SO type implementing the `isExportable` API
+ savedObjects.registerType<{ enabled: boolean; title: string }>({
+ name: 'test-is-exportable',
+ hidden: false,
+ namespaceType: 'single',
+ mappings: {
+ properties: {
+ title: { type: 'text' },
+ enabled: { type: 'boolean' },
+ },
+ },
+ management: {
+ defaultSearchField: 'title',
+ importableAndExportable: true,
+ getTitle: (obj) => obj.attributes.title,
+ isExportable: (obj) => {
+ if (obj.id === 'error') {
+ throw new Error('something went wrong');
+ }
+ return obj.attributes.enabled === true;
+ },
+ },
+ });
}
public start() {}
diff --git a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts
index 0351c5abdde46..8437e050091fb 100644
--- a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts
+++ b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts
@@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import type { SavedObject } from '../../../../src/core/types';
+import type { SavedObjectsExportResultDetails } from '../../../../src/core/server';
import { PluginFunctionalProviderContext } from '../../services';
function parseNdJson(input: string): Array> {
@@ -139,7 +140,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
});
});
- describe('FOO nested export transforms', () => {
+ describe('nested export transforms', () => {
before(async () => {
await esArchiver.load(
'test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform'
@@ -183,5 +184,121 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
});
});
});
+
+ describe('isExportable API', () => {
+ before(async () => {
+ await esArchiver.load(
+ 'test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion'
+ );
+ });
+
+ after(async () => {
+ await esArchiver.unload(
+ 'test/functional/fixtures/es_archiver/saved_objects_management/export_exclusion'
+ );
+ });
+
+ it('should only export objects returning `true` for `isExportable`', async () => {
+ await supertest
+ .post('/api/saved_objects/_export')
+ .set('kbn-xsrf', 'true')
+ .send({
+ objects: [
+ {
+ type: 'test-is-exportable',
+ id: '1',
+ },
+ ],
+ includeReferencesDeep: true,
+ excludeExportDetails: true,
+ })
+ .expect(200)
+ .then((resp) => {
+ const objects = parseNdJson(resp.text).sort((obj1, obj2) =>
+ obj1.id.localeCompare(obj2.id)
+ );
+ expect(objects.map((obj) => `${obj.type}:${obj.id}`)).to.eql([
+ 'test-is-exportable:1',
+ 'test-is-exportable:3',
+ 'test-is-exportable:5',
+ ]);
+ });
+ });
+
+ it('lists objects that got filtered', async () => {
+ await supertest
+ .post('/api/saved_objects/_export')
+ .set('kbn-xsrf', 'true')
+ .send({
+ objects: [
+ {
+ type: 'test-is-exportable',
+ id: '1',
+ },
+ ],
+ includeReferencesDeep: true,
+ excludeExportDetails: false,
+ })
+ .expect(200)
+ .then((resp) => {
+ const objects = parseNdJson(resp.text);
+ const exportDetails = (objects[
+ objects.length - 1
+ ] as unknown) as SavedObjectsExportResultDetails;
+
+ expect(exportDetails.excludedObjectsCount).to.eql(2);
+ expect(exportDetails.excludedObjects).to.eql([
+ {
+ type: 'test-is-exportable',
+ id: '2',
+ reason: 'excluded',
+ },
+ {
+ type: 'test-is-exportable',
+ id: '4',
+ reason: 'excluded',
+ },
+ ]);
+ });
+ });
+
+ it('excludes objects if `isExportable` throws', async () => {
+ await supertest
+ .post('/api/saved_objects/_export')
+ .set('kbn-xsrf', 'true')
+ .send({
+ objects: [
+ {
+ type: 'test-is-exportable',
+ id: '5',
+ },
+ {
+ type: 'test-is-exportable',
+ id: 'error',
+ },
+ ],
+ includeReferencesDeep: true,
+ excludeExportDetails: false,
+ })
+ .expect(200)
+ .then((resp) => {
+ const objects = parseNdJson(resp.text);
+ expect(objects.length).to.eql(2);
+ expect([objects[0]].map((obj) => `${obj.type}:${obj.id}`)).to.eql([
+ 'test-is-exportable:5',
+ ]);
+ const exportDetails = (objects[
+ objects.length - 1
+ ] as unknown) as SavedObjectsExportResultDetails;
+ expect(exportDetails.excludedObjects).to.eql([
+ {
+ type: 'test-is-exportable',
+ id: 'error',
+ reason: 'predicate_error',
+ },
+ ]);
+ });
+ });
+ });
});
}
diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts
index d9ebbac810231..ea2f321458c22 100644
--- a/x-pack/test/saved_object_api_integration/common/suites/export.ts
+++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts
@@ -21,12 +21,15 @@ const {
export interface ExportTestDefinition extends TestDefinition {
request: ReturnType;
}
+
export type ExportTestSuite = TestSuite;
+
interface SuccessResult {
type: string;
id: string;
originId?: string;
}
+
export interface ExportTestCase {
title: string;
type: string;
@@ -135,7 +138,13 @@ export const createRequest = ({ type, id }: ExportTestCase) =>
const getTestTitle = ({ failure, title }: ExportTestCase) =>
`${failure?.reason || 'success'} ["${title}"]`;
-const EMPTY_RESULT = { exportedCount: 0, missingRefCount: 0, missingReferences: [] };
+const EMPTY_RESULT = {
+ excludedObjects: [],
+ excludedObjectsCount: 0,
+ exportedCount: 0,
+ missingRefCount: 0,
+ missingReferences: [],
+};
export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
const expectSavedObjectForbiddenBulkGet = expectResponses.forbiddenTypes('bulk_get');
@@ -189,6 +198,8 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest