[7.x] Sharing saved objects, phase 2 (#80945) (#88917)

This commit is contained in:
Joe Portner 2021-01-22 16:53:47 -05:00 committed by GitHub
parent 104fdb8f63
commit 73d1f644cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 4924 additions and 875 deletions

View file

@ -10,6 +10,8 @@ The following saved objects APIs are available:
* <<saved-objects-api-get, Get object API>> to retrieve a single {kib} saved object by ID
* <<saved-objects-api-resolve, Resolve object API>> to retrieve a single {kib} saved object by ID, using any legacy URL alias if it exists
* <<saved-objects-api-bulk-get, Bulk get objects API>> to retrieve multiple {kib} saved objects by ID
* <<saved-objects-api-find, Find objects API>> to retrieve a paginated set of {kib} saved objects by various conditions
@ -40,4 +42,5 @@ include::saved-objects/delete.asciidoc[]
include::saved-objects/export.asciidoc[]
include::saved-objects/import.asciidoc[]
include::saved-objects/resolve_import_errors.asciidoc[]
include::saved-objects/resolve.asciidoc[]
include::saved-objects/rotate_encryption_key.asciidoc[]

View file

@ -80,7 +80,7 @@ The API returns the following:
"title": "[Flights] Global Flight Dashboard",
"hits": 0,
"description": "Analyze mock flight data for ES-Air, Logstash Airways, Kibana Airlines and JetBeats",
"panelsJSON": "[{\"panelIndex\":\"1\",\"gridData\":{\"x\":0,\"y\":0,\"w\":32,\"h\":7,\"i\":\"1\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_0\"},{\"panelIndex\":\"3\",\"gridData\":{\"x\":17,\"y\":7,\"w\":23,\"h\":12,\"i\":\"3\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Average Ticket Price\":\"#0A50A1\",\"Flight Count\":\"#82B5D8\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_1\"},{\"panelIndex\":\"4\",\"gridData\":{\"x\":0,\"y\":85,\"w\":48,\"h\":15,\"i\":\"4\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_2\"},{\"panelIndex\":\"5\",\"gridData\":{\"x\":0,\"y\":7,\"w\":17,\"h\":12,\"i\":\"5\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"ES-Air\":\"#447EBC\",\"JetBeats\":\"#65C5DB\",\"Kibana Airlines\":\"#BA43A9\",\"Logstash Airways\":\"#E5AC0E\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_3\"},{\"panelIndex\":\"6\",\"gridData\":{\"x\":24,\"y\":33,\"w\":24,\"h\":14,\"i\":\"6\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Carrier Delay\":\"#5195CE\",\"Late Aircraft Delay\":\"#1F78C1\",\"NAS Delay\":\"#70DBED\",\"No Delay\":\"#BADFF4\",\"Security Delay\":\"#052B51\",\"Weather Delay\":\"#6ED0E0\"}}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_4\"},{\"panelIndex\":\"7\",\"gridData\":{\"x\":24,\"y\":19,\"w\":24,\"h\":14,\"i\":\"7\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_5\"},{\"panelIndex\":\"10\",\"gridData\":{\"x\":0,\"y\":35,\"w\":24,\"h\":12,\"i\":\"10\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_6\"},{\"panelIndex\":\"13\",\"gridData\":{\"x\":10,\"y\":19,\"w\":14,\"h\":8,\"i\":\"13\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_7\"},{\"panelIndex\":\"14\",\"gridData\":{\"x\":10,\"y\":27,\"w\":14,\"h\":8,\"i\":\"14\"},\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_8\"},{\"panelIndex\":\"18\",\"gridData\":{\"x\":24,\"y\":70,\"w\":24,\"h\":15,\"i\":\"18\"},\"embeddableConfig\":{\"mapCenter\":[27.421687059550266,15.371002131141724],\"mapZoom\":1},\"version\":\"6.3.0\",\"panelRefName\":\"panel_9\"},{\"panelIndex\":\"21\",\"gridData\":{\"x\":0,\"y\":62,\"w\":48,\"h\":8,\"i\":\"21\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_10\"},{\"panelIndex\":\"22\",\"gridData\":{\"x\":32,\"y\":0,\"w\":16,\"h\":7,\"i\":\"22\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_11\"},{\"panelIndex\":\"23\",\"gridData\":{\"x\":0,\"y\":70,\"w\":24,\"h\":15,\"i\":\"23\"},\"embeddableConfig\":{\"mapCenter\":[42.19556096274418,9.536742995308601e-7],\"mapZoom\":1},\"version\":\"6.3.0\",\"panelRefName\":\"panel_12\"},{\"panelIndex\":\"25\",\"gridData\":{\"x\":0,\"y\":19,\"w\":10,\"h\":8,\"i\":\"25\"},\"embeddableConfig\":{\"vis\":{\"defaultColors\":{\"0 - 50\":\"rgb(247,251,255)\",\"100 - 150\":\"rgb(107,174,214)\",\"150 - 200\":\"rgb(33,113,181)\",\"200 - 250\":\"rgb(8,48,107)\",\"50 - 100\":\"rgb(198,219,239)\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_13\"},{\"panelIndex\":\"27\",\"gridData\":{\"x\":0,\"y\":27,\"w\":10,\"h\":8,\"i\":\"27\"},\"embeddableConfig\":{\"vis\":{\"defaultColors\":{\"0 - 50\":\"rgb(247,251,255)\",\"100 - 150\":\"rgb(107,174,214)\",\"150 - 200\":\"rgb(33,113,181)\",\"200 - 250\":\"rgb(8,48,107)\",\"50 - 100\":\"rgb(198,219,239)\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_14\"},{\"panelIndex\":\"28\",\"gridData\":{\"x\":0,\"y\":47,\"w\":24,\"h\":15,\"i\":\"28\"},\"embeddableConfig\":{\"vis\":{\"defaultColors\":{\"0 -* Connection #0 to host 69c72adb58fa46c69a01afdf4a6cbfd3.us-west1.gcp.cloud.es.io left intact\n 11\":\"rgb(247,251,255)\",\"11 - 22\":\"rgb(208,225,242)\",\"22 - 33\":\"rgb(148,196,223)\",\"33 - 44\":\"rgb(74,152,201)\",\"44 - 55\":\"rgb(23,100,171)\"},\"legendOpen\":false}},\"version\":\"6.3.0\",\"panelRefName\":\"panel_15\"},{\"panelIndex\":\"29\",\"gridData\":{\"x\":40,\"y\":7,\"w\":8,\"h\":6,\"i\":\"29\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_16\"},{\"panelIndex\":\"30\",\"gridData\":{\"x\":40,\"y\":13,\"w\":8,\"h\":6,\"i\":\"30\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_17\"},{\"panelIndex\":\"31\",\"gridData\":{\"x\":24,\"y\":47,\"w\":24,\"h\":15,\"i\":\"31\"},\"embeddableConfig\":{},\"version\":\"6.3.0\",\"panelRefName\":\"panel_18\"}]",
"panelsJSON": "[ . . . ]",
"optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}",
"version": 1,
"timeRestore": true,

View file

@ -0,0 +1,130 @@
[[saved-objects-api-resolve]]
=== Resolve object API
++++
<titleabbrev>Resolve object</titleabbrev>
++++
experimental[] Retrieve a single {kib} saved object by ID, using any legacy URL alias if it exists.
Under certain circumstances, when Kibana is upgraded, saved object migrations may necessitate regenerating some object IDs to enable new
features. When an object's ID is regenerated, a legacy URL alias is created for that object, preserving its old ID. In such a scenario, that
object can be retrieved via the Resolve API using either its new ID or its old ID.
[[saved-objects-api-resolve-request]]
==== Request
`GET <kibana host>:<port>/api/saved_objects/resolve/<type>/<id>`
`GET <kibana host>:<port>/s/<space_id>/api/saved_objects/resolve/<type>/<id>`
[[saved-objects-api-resolve-params]]
==== Path parameters
`space_id`::
(Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used.
`type`::
(Required, string) Valid options include `visualization`, `dashboard`, `search`, `index-pattern`, `config`, and `timelion-sheet`.
`id`::
(Required, string) The ID of the object to retrieve.
[[saved-objects-api-resolve-codes]]
==== Response code
`200`::
Indicates a successful call.
[[saved-objects-api-resolve-example]]
==== Example
Retrieve the index pattern object with the `my-pattern` ID:
[source,sh]
--------------------------------------------------
$ curl -X GET api/saved_objects/resolve/index-pattern/my-pattern
--------------------------------------------------
// KIBANA
The API returns the following:
[source,sh]
--------------------------------------------------
{
"saved_object": {
"id": "my-pattern",
"type": "index-pattern",
"version": 1,
"attributes": {
"title": "my-pattern-*"
}
},
"outcome": "exactMatch"
}
--------------------------------------------------
The `outcome` field may be any of the following:
* `"exactMatch"` -- One document exactly matched the given ID.
* `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID.
* `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID.
Retrieve a dashboard object in the `testspace` by ID:
[source,sh]
--------------------------------------------------
$ curl -X GET s/testspace/api/saved_objects/resolve/dashboard/7adfa750-4c81-11e8-b3d7-01146121b73d
--------------------------------------------------
// KIBANA
The API returns the following:
[source,sh]
--------------------------------------------------
{
"saved_object": {
"id": "7adfa750-4c81-11e8-b3d7-01146121b73d",
"type": "dashboard",
"updated_at": "2019-07-23T00:11:07.059Z",
"version": "WzQ0LDFd",
"attributes": {
"title": "[Flights] Global Flight Dashboard",
"hits": 0,
"description": "Analyze mock flight data for ES-Air, Logstash Airways, Kibana Airlines and JetBeats",
"panelsJSON": "[ . . . ]",
"optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}",
"version": 1,
"timeRestore": true,
"timeTo": "now",
"timeFrom": "now-24h",
"refreshInterval": {
"display": "15 minutes",
"pause": false,
"section": 2,
"value": 900000
},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}"
}
},
"references": [
{
"name": "panel_0",
"type": "visualization",
"id": "aeb212e0-4c84-11e8-b3d7-01146121b73d"
},
. . .
{
"name": "panel_18",
"type": "visualization",
"id": "ed78a660-53a0-11e8-acbd-0be0ad9d822b"
}
],
"migrationVersion": {
"dashboard": "7.0.0"
}
},
"outcome": "conflict"
}
--------------------------------------------------

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObject](./kibana-plugin-core-public.savedobject.md) &gt; [coreMigrationVersion](./kibana-plugin-core-public.savedobject.coremigrationversion.md)
## SavedObject.coreMigrationVersion property
A semver value that is used when upgrading objects between Kibana versions.
<b>Signature:</b>
```typescript
coreMigrationVersion?: string;
```

View file

@ -15,6 +15,7 @@ export interface SavedObject<T = unknown>
| Property | Type | Description |
| --- | --- | --- |
| [attributes](./kibana-plugin-core-public.savedobject.attributes.md) | <code>T</code> | The data for a Saved Object is stored as an object in the <code>attributes</code> property. |
| [coreMigrationVersion](./kibana-plugin-core-public.savedobject.coremigrationversion.md) | <code>string</code> | A semver value that is used when upgrading objects between Kibana versions. |
| [error](./kibana-plugin-core-public.savedobject.error.md) | <code>SavedObjectError</code> | |
| [id](./kibana-plugin-core-public.savedobject.id.md) | <code>string</code> | The ID of this Saved Object, guaranteed to be unique for all objects of the same <code>type</code> |
| [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsCreateOptions](./kibana-plugin-core-public.savedobjectscreateoptions.md) &gt; [coreMigrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md)
## SavedObjectsCreateOptions.coreMigrationVersion property
A semver value that is used when upgrading objects between Kibana versions.
<b>Signature:</b>
```typescript
coreMigrationVersion?: string;
```

View file

@ -15,6 +15,7 @@ export interface SavedObjectsCreateOptions
| Property | Type | Description |
| --- | --- | --- |
| [coreMigrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md) | <code>string</code> | A semver value that is used when upgrading objects between Kibana versions. |
| [id](./kibana-plugin-core-public.savedobjectscreateoptions.id.md) | <code>string</code> | (Not recommended) Specify an id instead of having the saved objects service generate one for you. |
| [migrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |
| [overwrite](./kibana-plugin-core-public.savedobjectscreateoptions.overwrite.md) | <code>boolean</code> | If a document with the given <code>id</code> already exists, overwrite it's contents (default=false). |

View file

@ -9,7 +9,7 @@ Constructs a new instance of the `SimpleSavedObject` class
<b>Signature:</b>
```typescript
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType<T>);
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObjectType<T>);
```
## Parameters
@ -17,5 +17,5 @@ constructor(client: SavedObjectsClientContract, { id, type, version, attributes,
| Parameter | Type | Description |
| --- | --- | --- |
| client | <code>SavedObjectsClientContract</code> | |
| { id, type, version, attributes, error, references, migrationVersion } | <code>SavedObjectType&lt;T&gt;</code> | |
| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, } | <code>SavedObjectType&lt;T&gt;</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) &gt; [coreMigrationVersion](./kibana-plugin-core-public.simplesavedobject.coremigrationversion.md)
## SimpleSavedObject.coreMigrationVersion property
<b>Signature:</b>
```typescript
coreMigrationVersion: SavedObjectType<T>['coreMigrationVersion'];
```

View file

@ -18,7 +18,7 @@ export declare class SimpleSavedObject<T = unknown>
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the <code>SimpleSavedObject</code> class |
| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the <code>SimpleSavedObject</code> class |
## Properties
@ -26,6 +26,7 @@ export declare class SimpleSavedObject<T = unknown>
| --- | --- | --- | --- |
| [\_version](./kibana-plugin-core-public.simplesavedobject._version.md) | | <code>SavedObjectType&lt;T&gt;['version']</code> | |
| [attributes](./kibana-plugin-core-public.simplesavedobject.attributes.md) | | <code>T</code> | |
| [coreMigrationVersion](./kibana-plugin-core-public.simplesavedobject.coremigrationversion.md) | | <code>SavedObjectType&lt;T&gt;['coreMigrationVersion']</code> | |
| [error](./kibana-plugin-core-public.simplesavedobject.error.md) | | <code>SavedObjectType&lt;T&gt;['error']</code> | |
| [id](./kibana-plugin-core-public.simplesavedobject.id.md) | | <code>SavedObjectType&lt;T&gt;['id']</code> | |
| [migrationVersion](./kibana-plugin-core-public.simplesavedobject.migrationversion.md) | | <code>SavedObjectType&lt;T&gt;['migrationVersion']</code> | |

View file

@ -139,7 +139,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectAttributes](./kibana-plugin-core-server.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the <code>attributes</code> property. |
| [SavedObjectExportBaseOptions](./kibana-plugin-core-server.savedobjectexportbaseoptions.md) | |
| [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) | Migration context provided when invoking a [migration handler](./kibana-plugin-core-server.savedobjectmigrationfn.md) |
| [SavedObjectMigrationMap](./kibana-plugin-core-server.savedobjectmigrationmap.md) | A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions.<!-- -->For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. |
| [SavedObjectMigrationMap](./kibana-plugin-core-server.savedobjectmigrationmap.md) | A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions, and they cannot exceed the current Kibana version.<!-- -->For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. |
| [SavedObjectReference](./kibana-plugin-core-server.savedobjectreference.md) | A reference to another saved object. |
| [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) | |
| [SavedObjectsAddToNamespacesResponse](./kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md) | |
@ -188,10 +188,12 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsMigrationLogger](./kibana-plugin-core-server.savedobjectsmigrationlogger.md) | |
| [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |
| [SavedObjectsRawDoc](./kibana-plugin-core-server.savedobjectsrawdoc.md) | A raw document as represented directly in the saved object index. |
| [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) | Options that can be specified when using the saved objects serializer to parse a raw document. |
| [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) | |
| [SavedObjectsRemoveReferencesToResponse](./kibana-plugin-core-server.savedobjectsremovereferencestoresponse.md) | |
| [SavedObjectsRepositoryFactory](./kibana-plugin-core-server.savedobjectsrepositoryfactory.md) | Factory provided when invoking a [client factory provider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) See [SavedObjectsServiceSetup.setClientFactoryProvider](./kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) |
| [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) | Options to control the "resolve import" operation. |
| [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) | |
| [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | Saved Objects is Kibana's data persistence mechanism allowing plugins to use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods for registering Saved Object types, creating and registering Saved Object client wrappers and factories. |
| [SavedObjectsServiceStart](./kibana-plugin-core-server.savedobjectsservicestart.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing and querying state. The SavedObjectsServiceStart API provides a scoped Saved Objects client for interacting with Saved Objects. |
| [SavedObjectStatusMeta](./kibana-plugin-core-server.savedobjectstatusmeta.md) | Meta information about the SavedObjectService's status. Available to plugins via [CoreSetup.status](./kibana-plugin-core-server.coresetup.status.md)<!-- -->. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObject](./kibana-plugin-core-server.savedobject.md) &gt; [coreMigrationVersion](./kibana-plugin-core-server.savedobject.coremigrationversion.md)
## SavedObject.coreMigrationVersion property
A semver value that is used when upgrading objects between Kibana versions.
<b>Signature:</b>
```typescript
coreMigrationVersion?: string;
```

View file

@ -15,6 +15,7 @@ export interface SavedObject<T = unknown>
| Property | Type | Description |
| --- | --- | --- |
| [attributes](./kibana-plugin-core-server.savedobject.attributes.md) | <code>T</code> | The data for a Saved Object is stored as an object in the <code>attributes</code> property. |
| [coreMigrationVersion](./kibana-plugin-core-server.savedobject.coremigrationversion.md) | <code>string</code> | A semver value that is used when upgrading objects between Kibana versions. |
| [error](./kibana-plugin-core-server.savedobject.error.md) | <code>SavedObjectError</code> | |
| [id](./kibana-plugin-core-server.savedobject.id.md) | <code>string</code> | The ID of this Saved Object, guaranteed to be unique for all objects of the same <code>type</code> |
| [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |

View file

@ -4,7 +4,7 @@
## SavedObjectMigrationMap interface
A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions.
A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions, and they cannot exceed the current Kibana version.
For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one.

View file

@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) &gt; [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md)
## SavedObjectsBulkCreateObject.coreMigrationVersion property
A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error.
<b>Signature:</b>
```typescript
coreMigrationVersion?: string;
```
## Remarks
Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion` field set and you want to create it again.

View file

@ -16,6 +16,7 @@ export interface SavedObjectsBulkCreateObject<T = unknown>
| Property | Type | Description |
| --- | --- | --- |
| [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | <code>T</code> | |
| [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) | <code>string</code> | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. |
| [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | <code>string</code> | |
| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | <code>string[]</code> | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md)<!-- -->.<!-- -->Note: this can only be used for multi-namespace object types. |
| [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |

View file

@ -36,5 +36,6 @@ The constructor for this class is marked as internal. Third-party code should no
| [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query |
| [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object |
| [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {<!-- -->type, id<!-- -->} tuple to remove the said reference. |
| [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists |
| [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject |

View file

@ -0,0 +1,26 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) &gt; [resolve](./kibana-plugin-core-server.savedobjectsclient.resolve.md)
## SavedObjectsClient.resolve() method
Resolves a single object, using any legacy URL alias if it exists
<b>Signature:</b>
```typescript
resolve<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObjectsResolveResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| type | <code>string</code> | The type of SavedObject to retrieve |
| id | <code>string</code> | The ID of the SavedObject to retrieve |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsResolveResponse<T>>`

View file

@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) &gt; [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md)
## SavedObjectsCreateOptions.coreMigrationVersion property
A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error.
<b>Signature:</b>
```typescript
coreMigrationVersion?: string;
```
## Remarks
Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion` field set and you want to create it again.

View file

@ -15,6 +15,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions
| Property | Type | Description |
| --- | --- | --- |
| [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) | <code>string</code> | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. |
| [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | <code>string</code> | (not recommended) Specify an id for the document |
| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | <code>string[]</code> | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md)<!-- -->.<!-- -->Note: this can only be used for multi-namespace object types. |
| [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md)
## SavedObjectsRawDocParseOptions interface
Options that can be specified when using the saved objects serializer to parse a raw document.
<b>Signature:</b>
```typescript
export interface SavedObjectsRawDocParseOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [namespaceTreatment](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md) | <code>'strict' &#124; 'lax'</code> | Optional setting to allow for lax handling of the raw document ID and namespace field. This is needed when a previously single-namespace object type is converted to a multi-namespace object type, and it is only intended to be used during upgrade migrations.<!-- -->If not specified, the default treatment is <code>strict</code>. |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) &gt; [namespaceTreatment](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md)
## SavedObjectsRawDocParseOptions.namespaceTreatment property
Optional setting to allow for lax handling of the raw document ID and namespace field. This is needed when a previously single-namespace object type is converted to a multi-namespace object type, and it is only intended to be used during upgrade migrations.
If not specified, the default treatment is `strict`<!-- -->.
<b>Signature:</b>
```typescript
namespaceTreatment?: 'strict' | 'lax';
```

View file

@ -28,5 +28,6 @@ export declare class SavedObjectsRepository
| [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object |
| [incrementCounter(type, id, counterFields, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields (by one by default). Creates the document if one doesn't exist for the given id. |
| [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {<!-- -->type, id<!-- -->} tuple to remove the said reference. |
| [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists |
| [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object |

View file

@ -0,0 +1,28 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) &gt; [resolve](./kibana-plugin-core-server.savedobjectsrepository.resolve.md)
## SavedObjectsRepository.resolve() method
Resolves a single object, using any legacy URL alias if it exists
<b>Signature:</b>
```typescript
resolve<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObjectsResolveResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| type | <code>string</code> | |
| id | <code>string</code> | |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsResolveResponse<T>>`
{<!-- -->promise<!-- -->} - { saved\_object, outcome }

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md)
## SavedObjectsResolveResponse interface
<b>Signature:</b>
```typescript
export interface SavedObjectsResolveResponse<T = unknown>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | <code>'exactMatch' &#124; 'aliasMatch' &#124; 'conflict'</code> | The outcome for a successful <code>resolve</code> call is one of the following values:<!-- -->\* <code>'exactMatch'</code> -- One document exactly matched the given ID. \* <code>'aliasMatch'</code> -- One document with a legacy URL alias matched the given ID; in this case the <code>saved_object.id</code> field is different than the given ID. \* <code>'conflict'</code> -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the <code>saved_object</code> object is the exact match, and the <code>saved_object.id</code> field is the same as the given ID. |
| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | <code>SavedObject&lt;T&gt;</code> | |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) &gt; [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md)
## SavedObjectsResolveResponse.outcome property
The outcome for a successful `resolve` call is one of the following values:
\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID.
<b>Signature:</b>
```typescript
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) &gt; [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md)
## SavedObjectsResolveResponse.saved\_object property
<b>Signature:</b>
```typescript
saved_object: SavedObject<T>;
```

View file

@ -0,0 +1,26 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsSerializer](./kibana-plugin-core-server.savedobjectsserializer.md) &gt; [generateRawLegacyUrlAliasId](./kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md)
## SavedObjectsSerializer.generateRawLegacyUrlAliasId() method
Given a saved object type and id, generates the compound id that is stored in the raw document for its legacy URL alias.
<b>Signature:</b>
```typescript
generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| namespace | <code>string</code> | |
| type | <code>string</code> | |
| id | <code>string</code> | |
<b>Returns:</b>
`string`

View file

@ -9,14 +9,15 @@ Determines whether or not the raw document can be converted to a saved object.
<b>Signature:</b>
```typescript
isRawSavedObject(rawDoc: SavedObjectsRawDoc): boolean;
isRawSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): boolean;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| rawDoc | <code>SavedObjectsRawDoc</code> | |
| doc | <code>SavedObjectsRawDoc</code> | |
| options | <code>SavedObjectsRawDocParseOptions</code> | |
<b>Returns:</b>

View file

@ -23,7 +23,8 @@ The constructor for this class is marked as internal. Third-party code should no
| Method | Modifiers | Description |
| --- | --- | --- |
| [generateRawId(namespace, type, id)](./kibana-plugin-core-server.savedobjectsserializer.generaterawid.md) | | Given a saved object type and id, generates the compound id that is stored in the raw document. |
| [isRawSavedObject(rawDoc)](./kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md) | | Determines whether or not the raw document can be converted to a saved object. |
| [rawToSavedObject(doc)](./kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md) | | Converts a document from the format that is stored in elasticsearch to the saved object client format. |
| [generateRawLegacyUrlAliasId(namespace, type, id)](./kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md) | | Given a saved object type and id, generates the compound id that is stored in the raw document for its legacy URL alias. |
| [isRawSavedObject(doc, options)](./kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md) | | Determines whether or not the raw document can be converted to a saved object. |
| [rawToSavedObject(doc, options)](./kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md) | | Converts a document from the format that is stored in elasticsearch to the saved object client format. |
| [savedObjectToRaw(savedObj)](./kibana-plugin-core-server.savedobjectsserializer.savedobjecttoraw.md) | | Converts a document from the saved object client format to the format that is stored in elasticsearch. |

View file

@ -9,7 +9,7 @@ Converts a document from the format that is stored in elasticsearch to the saved
<b>Signature:</b>
```typescript
rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc;
rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc;
```
## Parameters
@ -17,6 +17,7 @@ rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc;
| Parameter | Type | Description |
| --- | --- | --- |
| doc | <code>SavedObjectsRawDoc</code> | |
| options | <code>SavedObjectsRawDocParseOptions</code> | |
<b>Returns:</b>

View file

@ -0,0 +1,42 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) &gt; [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md)
## SavedObjectsType.convertToMultiNamespaceTypeVersion property
If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.
Requirements:
1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)
Example of a single-namespace type in 7.10:
```ts
{
name: 'foo',
hidden: false,
namespaceType: 'single',
mappings: {...}
}
```
Example after converting to a multi-namespace type in 7.11:
```ts
{
name: 'foo',
hidden: false,
namespaceType: 'multiple',
mappings: {...},
convertToMultiNamespaceTypeVersion: '7.11.0'
}
```
Note: a migration function can be optionally specified for the same version.
<b>Signature:</b>
```typescript
convertToMultiNamespaceTypeVersion?: string;
```

View file

@ -19,6 +19,28 @@ This is only internal for now, and will only be public when we expose the regist
| Property | Type | Description |
| --- | --- | --- |
| [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | <code>string</code> | If defined, will be used to convert the type to an alias. |
| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | <code>string</code> | If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.<!-- -->Requirements:<!-- -->1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)<!-- -->Example of a single-namespace type in 7.10:
```ts
{
name: 'foo',
hidden: false,
namespaceType: 'single',
mappings: {...}
}
```
Example after converting to a multi-namespace type in 7.11:
```ts
{
name: 'foo',
hidden: false,
namespaceType: 'multiple',
mappings: {...},
convertToMultiNamespaceTypeVersion: '7.11.0'
}
```
Note: a migration function can be optionally specified for the same version. |
| [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | <code>boolean</code> | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an <code>extraType</code> when creating the repository.<!-- -->See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md)<!-- -->. |
| [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | <code>string</code> | 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) | <code>SavedObjectsTypeManagementDefinition</code> | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. |

View file

@ -12,7 +12,7 @@ start(core: CoreStart): {
fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise<import("../common").FieldFormatsRegistry>;
};
indexPatterns: {
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../core/server").SavedObjectsClient, "update" | "find" | "get" | "delete" | "errors" | "create" | "bulkCreate" | "checkConflicts" | "bulkGet" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo">, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise<import("../public").IndexPatternsService>;
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../core/server").SavedObjectsClient, "update" | "find" | "get" | "resolve" | "delete" | "errors" | "create" | "bulkCreate" | "checkConflicts" | "bulkGet" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo">, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise<import("../public").IndexPatternsService>;
};
search: ISearchStart<import("./search").IEsSearchRequest, import("./search").IEsSearchResponse<any>>;
};
@ -31,7 +31,7 @@ start(core: CoreStart): {
fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise<import("../common").FieldFormatsRegistry>;
};
indexPatterns: {
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../core/server").SavedObjectsClient, "update" | "find" | "get" | "delete" | "errors" | "create" | "bulkCreate" | "checkConflicts" | "bulkGet" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo">, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise<import("../public").IndexPatternsService>;
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../core/server").SavedObjectsClient, "update" | "find" | "get" | "resolve" | "delete" | "errors" | "create" | "bulkCreate" | "checkConflicts" | "bulkGet" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo">, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise<import("../public").IndexPatternsService>;
};
search: ISearchStart<import("./search").IEsSearchRequest, import("./search").IEsSearchResponse<any>>;
}`

View file

@ -194,6 +194,10 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | User has accessed a saved object.
| `failure` | User is not authorized to access a saved object.
.2+| `saved_object_resolve`
| `success` | User has accessed a saved object.
| `failure` | User is not authorized to access a saved object.
.2+| `saved_object_find`
| `success` | User has accessed a saved object as part of a search operation.
| `failure` | User is not authorized to search for saved objects.

View file

@ -1034,6 +1034,7 @@ export type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
// @public (undocumented)
export interface SavedObject<T = unknown> {
attributes: T;
coreMigrationVersion?: string;
// (undocumented)
error?: SavedObjectError;
id: string;
@ -1151,6 +1152,7 @@ export type SavedObjectsClientContract = PublicMethodsOf<SavedObjectsClient>;
// @public (undocumented)
export interface SavedObjectsCreateOptions {
coreMigrationVersion?: string;
id?: string;
migrationVersion?: SavedObjectsMigrationVersion;
overwrite?: boolean;
@ -1384,10 +1386,12 @@ export class ScopedHistory<HistoryLocationState = unknown> implements History<Hi
// @public
export class SimpleSavedObject<T = unknown> {
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObject<T>);
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObject<T>);
// (undocumented)
attributes: T;
// (undocumented)
coreMigrationVersion: SavedObject<T>['coreMigrationVersion'];
// (undocumented)
delete(): Promise<{}>;
// (undocumented)
error: SavedObject<T>['error'];

View file

@ -38,6 +38,8 @@ export interface SavedObjectsCreateOptions {
overwrite?: boolean;
/** {@inheritDoc SavedObjectsMigrationVersion} */
migrationVersion?: SavedObjectsMigrationVersion;
/** A semver value that is used when upgrading objects between Kibana versions. */
coreMigrationVersion?: string;
references?: SavedObjectReference[];
}

View file

@ -27,12 +27,22 @@ export class SimpleSavedObject<T = unknown> {
public id: SavedObjectType<T>['id'];
public type: SavedObjectType<T>['type'];
public migrationVersion: SavedObjectType<T>['migrationVersion'];
public coreMigrationVersion: SavedObjectType<T>['coreMigrationVersion'];
public error: SavedObjectType<T>['error'];
public references: SavedObjectType<T>['references'];
constructor(
private client: SavedObjectsClientContract,
{ id, type, version, attributes, error, references, migrationVersion }: SavedObjectType<T>
{
id,
type,
version,
attributes,
error,
references,
migrationVersion,
coreMigrationVersion,
}: SavedObjectType<T>
) {
this.id = id;
this.type = type;
@ -40,6 +50,7 @@ export class SimpleSavedObject<T = unknown> {
this.references = references || [];
this._version = version;
this.migrationVersion = migrationVersion;
this.coreMigrationVersion = coreMigrationVersion;
if (error) {
this.error = error;
}
@ -66,6 +77,7 @@ export class SimpleSavedObject<T = unknown> {
} else {
return this.client.create(this.type, this.attributes, {
migrationVersion: this.migrationVersion,
coreMigrationVersion: this.coreMigrationVersion,
references: this.references,
});
}

View file

@ -18,6 +18,7 @@ const createUsageStatsClientMock = () =>
incrementSavedObjectsDelete: jest.fn().mockResolvedValue(null),
incrementSavedObjectsFind: jest.fn().mockResolvedValue(null),
incrementSavedObjectsGet: jest.fn().mockResolvedValue(null),
incrementSavedObjectsResolve: jest.fn().mockResolvedValue(null),
incrementSavedObjectsUpdate: jest.fn().mockResolvedValue(null),
incrementSavedObjectsImport: jest.fn().mockResolvedValue(null),
incrementSavedObjectsResolveImportErrors: jest.fn().mockResolvedValue(null),

View file

@ -20,6 +20,7 @@ import {
DELETE_STATS_PREFIX,
FIND_STATS_PREFIX,
GET_STATS_PREFIX,
RESOLVE_STATS_PREFIX,
UPDATE_STATS_PREFIX,
IMPORT_STATS_PREFIX,
RESOLVE_IMPORT_STATS_PREFIX,
@ -594,6 +595,81 @@ describe('CoreUsageStatsClient', () => {
});
});
describe('#incrementSavedObjectsResolve', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
const request = httpServerMock.createKibanaRequest();
await expect(
usageStatsClient.incrementSavedObjectsResolve({
request,
} as BaseIncrementOptions)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalled();
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
const request = httpServerMock.createKibanaRequest();
await usageStatsClient.incrementSavedObjectsResolve({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${RESOLVE_STATS_PREFIX}.total`,
`${RESOLVE_STATS_PREFIX}.namespace.default.total`,
`${RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.no`,
],
incrementOptions
);
});
it('handles truthy options and the default namespace string appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup(DEFAULT_NAMESPACE_STRING);
const request = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders });
await usageStatsClient.incrementSavedObjectsResolve({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${RESOLVE_STATS_PREFIX}.total`,
`${RESOLVE_STATS_PREFIX}.namespace.default.total`,
`${RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.yes`,
],
incrementOptions
);
});
it('handles a non-default space appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup('foo');
const request = httpServerMock.createKibanaRequest();
await usageStatsClient.incrementSavedObjectsResolve({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${RESOLVE_STATS_PREFIX}.total`,
`${RESOLVE_STATS_PREFIX}.namespace.custom.total`,
`${RESOLVE_STATS_PREFIX}.namespace.custom.kibanaRequest.no`,
],
incrementOptions
);
});
});
describe('#incrementSavedObjectsUpdate', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();

View file

@ -40,6 +40,7 @@ export const CREATE_STATS_PREFIX = 'apiCalls.savedObjectsCreate';
export const DELETE_STATS_PREFIX = 'apiCalls.savedObjectsDelete';
export const FIND_STATS_PREFIX = 'apiCalls.savedObjectsFind';
export const GET_STATS_PREFIX = 'apiCalls.savedObjectsGet';
export const RESOLVE_STATS_PREFIX = 'apiCalls.savedObjectsResolve';
export const UPDATE_STATS_PREFIX = 'apiCalls.savedObjectsUpdate';
export const IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsImport';
export const RESOLVE_IMPORT_STATS_PREFIX = 'apiCalls.savedObjectsResolveImportErrors';
@ -53,6 +54,7 @@ const ALL_COUNTER_FIELDS = [
...getFieldsForCounter(DELETE_STATS_PREFIX),
...getFieldsForCounter(FIND_STATS_PREFIX),
...getFieldsForCounter(GET_STATS_PREFIX),
...getFieldsForCounter(RESOLVE_STATS_PREFIX),
...getFieldsForCounter(UPDATE_STATS_PREFIX),
// Saved Objects Management APIs
...getFieldsForCounter(IMPORT_STATS_PREFIX),
@ -123,6 +125,10 @@ export class CoreUsageStatsClient {
await this.updateUsageStats([], GET_STATS_PREFIX, options);
}
public async incrementSavedObjectsResolve(options: BaseIncrementOptions) {
await this.updateUsageStats([], RESOLVE_STATS_PREFIX, options);
}
public async incrementSavedObjectsUpdate(options: BaseIncrementOptions) {
await this.updateUsageStats([], UPDATE_STATS_PREFIX, options);
}

View file

@ -66,6 +66,13 @@ export interface CoreUsageStats {
'apiCalls.savedObjectsGet.namespace.custom.total'?: number;
'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsResolve.total'?: number;
'apiCalls.savedObjectsResolve.namespace.default.total'?: number;
'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no'?: number;
'apiCalls.savedObjectsResolve.namespace.custom.total'?: number;
'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsUpdate.total'?: number;
'apiCalls.savedObjectsUpdate.namespace.default.total'?: number;
'apiCalls.savedObjectsUpdate.namespace.default.kibanaRequest.yes'?: number;

View file

@ -277,10 +277,12 @@ export {
SavedObjectMigrationContext,
SavedObjectsMigrationLogger,
SavedObjectsRawDoc,
SavedObjectsRawDocParseOptions,
SavedObjectSanitizedDoc,
SavedObjectUnsanitizedDoc,
SavedObjectsRepositoryFactory,
SavedObjectsResolveImportErrorsOptions,
SavedObjectsResolveResponse,
SavedObjectsSerializer,
SavedObjectsUpdateOptions,
SavedObjectsUpdateResponse,

View file

@ -45,6 +45,7 @@ export {
export {
SavedObjectsSerializer,
SavedObjectsRawDoc,
SavedObjectsRawDocParseOptions,
SavedObjectSanitizedDoc,
SavedObjectUnsanitizedDoc,
} from './serialization';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
const mockUuidv5 = jest.fn().mockReturnValue('uuidv5');
Object.defineProperty(mockUuidv5, 'DNS', { value: 'DNSUUID', writable: false });
jest.mock('uuid/v5', () => mockUuidv5);
export { mockUuidv5 };

View file

@ -6,6 +6,7 @@ Object {
"migrationMappingPropertyHashes": Object {
"aaa": "625b32086eb1d1203564cf85062dd22e",
"bbb": "18c78c995965207ed3f6e7fc5c6e55fe",
"coreMigrationVersion": "2f4316de49999235636386fe51dc06c1",
"migrationVersion": "4a1746014a75ade3a714e1db5763276f",
"namespace": "2f4316de49999235636386fe51dc06c1",
"namespaces": "2f4316de49999235636386fe51dc06c1",
@ -23,6 +24,9 @@ Object {
"bbb": Object {
"type": "long",
},
"coreMigrationVersion": Object {
"type": "keyword",
},
"migrationVersion": Object {
"dynamic": "true",
"type": "object",
@ -64,6 +68,7 @@ exports[`buildActiveMappings handles the \`dynamic\` property of types 1`] = `
Object {
"_meta": Object {
"migrationMappingPropertyHashes": Object {
"coreMigrationVersion": "2f4316de49999235636386fe51dc06c1",
"firstType": "635418ab953d81d93f1190b70a8d3f57",
"migrationVersion": "4a1746014a75ade3a714e1db5763276f",
"namespace": "2f4316de49999235636386fe51dc06c1",
@ -78,6 +83,9 @@ Object {
},
"dynamic": "strict",
"properties": Object {
"coreMigrationVersion": Object {
"type": "keyword",
},
"firstType": Object {
"dynamic": "strict",
"properties": Object {

View file

@ -153,6 +153,9 @@ function defaultMapping(): IndexMapping {
},
},
},
coreMigrationVersion: {
type: 'keyword',
},
},
};
}

View file

@ -50,50 +50,102 @@
*/
import Boom from '@hapi/boom';
import uuidv5 from 'uuid/v5';
import { set } from '@elastic/safer-lodash-set';
import _ from 'lodash';
import Semver from 'semver';
import { Logger } from '../../../logging';
import { SavedObjectUnsanitizedDoc } from '../../serialization';
import { SavedObjectsMigrationVersion } from '../../types';
import {
SavedObjectsMigrationVersion,
SavedObjectsNamespaceType,
SavedObjectsType,
} from '../../types';
import { MigrationLogger } from './migration_logger';
import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { SavedObjectMigrationFn, SavedObjectMigrationMap } from '../types';
import { DEFAULT_NAMESPACE_STRING } from '../../service/lib/utils';
import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types';
export type TransformFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc;
const DEFAULT_MINIMUM_CONVERT_VERSION = '8.0.0';
export type MigrateFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc;
export type MigrateAndConvertFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc[];
interface TransformResult {
/**
* This is the original document that has been transformed.
*/
transformedDoc: SavedObjectUnsanitizedDoc;
/**
* These are any new document(s) that have been created during the transformation process; these are not transformed, but they are marked
* as up-to-date. Only conversion transforms generate additional documents.
*/
additionalDocs: SavedObjectUnsanitizedDoc[];
}
type ApplyTransformsFn = (
doc: SavedObjectUnsanitizedDoc,
options?: TransformOptions
) => TransformResult;
interface TransformOptions {
convertNamespaceTypes?: boolean;
}
interface DocumentMigratorOptions {
kibanaVersion: string;
typeRegistry: ISavedObjectTypeRegistry;
minimumConvertVersion?: string;
log: Logger;
}
interface ActiveMigrations {
[type: string]: {
latestVersion: string;
transforms: Array<{
version: string;
transform: TransformFn;
}>;
/** Derived from `migrate` transforms and `convert` transforms */
latestMigrationVersion?: string;
/** Derived from `reference` transforms */
latestCoreMigrationVersion?: string;
transforms: Transform[];
};
}
interface Transform {
version: string;
transform: (doc: SavedObjectUnsanitizedDoc) => TransformResult;
/**
* There are two "migrationVersion" transform types:
* * `migrate` - These transforms are defined and added by consumers using the type registry; each is applied to a single object type
* based on an object's `migrationVersion[type]` field. These are applied during index migrations and document migrations.
* * `convert` - These transforms are defined by core and added by consumers using the type registry; each is applied to a single object
* type based on an object's `migrationVersion[type]` field. These are applied during index migrations, NOT document migrations.
*
* There is one "coreMigrationVersion" transform type:
* * `reference` - These transforms are defined by core and added by consumers using the type registry; they are applied to all object
* types based on their `coreMigrationVersion` field. These are applied during index migrations, NOT document migrations.
*
* If any additional transform types are added, the functions below should be updated to account for them.
*/
transformType: 'migrate' | 'convert' | 'reference';
}
/**
* Manages migration of individual documents.
*/
export interface VersionedTransformer {
migrationVersion: SavedObjectsMigrationVersion;
migrate: MigrateFn;
migrateAndConvert: MigrateAndConvertFn;
prepareMigrations: () => void;
migrate: TransformFn;
}
/**
* A concrete implementation of the VersionedTransformer interface.
*/
export class DocumentMigrator implements VersionedTransformer {
private documentMigratorOptions: DocumentMigratorOptions;
private documentMigratorOptions: Omit<DocumentMigratorOptions, 'minimumConvertVersion'>;
private migrations?: ActiveMigrations;
private transformDoc?: TransformFn;
private transformDoc?: ApplyTransformsFn;
/**
* Creates an instance of DocumentMigrator.
@ -101,11 +153,19 @@ export class DocumentMigrator implements VersionedTransformer {
* @param {DocumentMigratorOptions} opts
* @prop {string} kibanaVersion - The current version of Kibana
* @prop {SavedObjectTypeRegistry} typeRegistry - The type registry to get type migrations from
* @prop {string} minimumConvertVersion - The minimum version of Kibana in which documents can be converted to multi-namespace types
* @prop {Logger} log - The migration logger
* @memberof DocumentMigrator
*/
constructor({ typeRegistry, kibanaVersion, log }: DocumentMigratorOptions) {
validateMigrationDefinition(typeRegistry);
constructor({
typeRegistry,
kibanaVersion: rawKibanaVersion,
minimumConvertVersion = DEFAULT_MINIMUM_CONVERT_VERSION,
log,
}: DocumentMigratorOptions) {
const kibanaVersion = rawKibanaVersion.split('-')[0]; // coerce a semver-like string (x.y.z-SNAPSHOT) or prerelease version (x.y.z-alpha) to a regular semver (x.y.z)
validateMigrationDefinition(typeRegistry, kibanaVersion, minimumConvertVersion);
this.documentMigratorOptions = { typeRegistry, kibanaVersion, log };
}
@ -120,7 +180,14 @@ export class DocumentMigrator implements VersionedTransformer {
if (!this.migrations) {
throw new Error('Migrations are not ready. Make sure prepareMigrations is called first.');
}
return _.mapValues(this.migrations, ({ latestVersion }) => latestVersion);
return Object.entries(this.migrations).reduce((acc, [prop, { latestMigrationVersion }]) => {
// some migration objects won't have a latestMigrationVersion (they only contain reference transforms that are applied from other types)
if (latestMigrationVersion) {
return { ...acc, [prop]: latestMigrationVersion };
}
return acc;
}, {});
}
/**
@ -132,7 +199,7 @@ export class DocumentMigrator implements VersionedTransformer {
public prepareMigrations = () => {
const { typeRegistry, kibanaVersion, log } = this.documentMigratorOptions;
this.migrations = buildActiveMigrations(typeRegistry, log);
this.migrations = buildActiveMigrations(typeRegistry, kibanaVersion, log);
this.transformDoc = buildDocumentTransform({
kibanaVersion,
migrations: this.migrations,
@ -155,25 +222,56 @@ export class DocumentMigrator implements VersionedTransformer {
// Ex: Importing sample data that is cached at import level, migrations would
// execute on mutated data the second time.
const clonedDoc = _.cloneDeep(doc);
return this.transformDoc(clonedDoc);
const { transformedDoc } = this.transformDoc(clonedDoc);
return transformedDoc;
};
/**
* Migrates a document to the latest version and applies type conversions if applicable. Also returns any additional document(s) that may
* have been created during the transformation process.
*
* @param {SavedObjectUnsanitizedDoc} doc
* @returns {SavedObjectUnsanitizedDoc}
* @memberof DocumentMigrator
*/
public migrateAndConvert = (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc[] => {
if (!this.migrations || !this.transformDoc) {
throw new Error('Migrations are not ready. Make sure prepareMigrations is called first.');
}
// Clone the document to prevent accidental mutations on the original data
// Ex: Importing sample data that is cached at import level, migrations would
// execute on mutated data the second time.
const clonedDoc = _.cloneDeep(doc);
const { transformedDoc, additionalDocs } = this.transformDoc(clonedDoc, {
convertNamespaceTypes: true,
});
return [transformedDoc, ...additionalDocs];
};
}
function validateMigrationsMapObject(name: string, migrationsMap?: SavedObjectMigrationMap) {
function validateMigrationsMapObject(
name: string,
kibanaVersion: string,
migrationsMap?: SavedObjectMigrationMap
) {
function assertObject(obj: any, prefix: string) {
if (!obj || typeof obj !== 'object') {
throw new Error(`${prefix} Got ${obj}.`);
}
}
function assertValidSemver(version: string, type: string) {
if (!Semver.valid(version)) {
throw new Error(
`Invalid migration for type ${type}. Expected all properties to be semvers, but got ${version}.`
);
}
if (Semver.gt(version, kibanaVersion)) {
throw new Error(
`Invalid migration for type ${type}. Property '${version}' cannot be greater than the current Kibana version '${kibanaVersion}'.`
);
}
}
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}.`);
@ -194,23 +292,63 @@ function validateMigrationsMapObject(name: string, migrationsMap?: SavedObjectMi
}
/**
* Basic validation that the migraiton definition matches our expectations. We can't
* Basic validation that the migration definition matches our expectations. We can't
* rely on TypeScript here, as the caller may be JavaScript / ClojureScript / any compile-to-js
* language. So, this is just to provide a little developer-friendly error messaging. Joi was
* giving weird errors, so we're just doing manual validation.
*/
function validateMigrationDefinition(registry: ISavedObjectTypeRegistry) {
function validateMigrationDefinition(
registry: ISavedObjectTypeRegistry,
kibanaVersion: string,
minimumConvertVersion: string
) {
function assertObjectOrFunction(entity: any, prefix: string) {
if (!entity || (typeof entity !== 'function' && typeof entity !== 'object')) {
throw new Error(`${prefix} Got! ${typeof entity}, ${JSON.stringify(entity)}.`);
}
}
function assertValidConvertToMultiNamespaceType(
namespaceType: SavedObjectsNamespaceType,
convertToMultiNamespaceTypeVersion: string,
type: string
) {
if (namespaceType !== 'multiple') {
throw new Error(
`Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple', but got '${namespaceType}'.`
);
} else if (!Semver.valid(convertToMultiNamespaceTypeVersion)) {
throw new Error(
`Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected value to be a semver, but got '${convertToMultiNamespaceTypeVersion}'.`
);
} else if (Semver.lt(convertToMultiNamespaceTypeVersion, minimumConvertVersion)) {
throw new Error(
`Invalid convertToMultiNamespaceTypeVersion for type ${type}. Value '${convertToMultiNamespaceTypeVersion}' cannot be less than '${minimumConvertVersion}'.`
);
} else if (Semver.gt(convertToMultiNamespaceTypeVersion, kibanaVersion)) {
throw new Error(
`Invalid convertToMultiNamespaceTypeVersion for type ${type}. Value '${convertToMultiNamespaceTypeVersion}' cannot be greater than the current Kibana version '${kibanaVersion}'.`
);
} else if (Semver.patch(convertToMultiNamespaceTypeVersion)) {
throw new Error(
`Invalid convertToMultiNamespaceTypeVersion for type ${type}. Value '${convertToMultiNamespaceTypeVersion}' cannot be used on a patch version (must be like 'x.y.0').`
);
}
}
registry.getAllTypes().forEach((type) => {
if (type.migrations) {
const { name, migrations, convertToMultiNamespaceTypeVersion, namespaceType } = type;
if (migrations) {
assertObjectOrFunction(
type.migrations,
`Migration for type ${type.name} should be an object or a function returning an object like { '2.0.0': (doc) => doc }.`
`Migration for type ${name} should be an object or a function returning an object like { '2.0.0': (doc) => doc }.`
);
}
if (convertToMultiNamespaceTypeVersion) {
assertValidConvertToMultiNamespaceType(
namespaceType,
convertToMultiNamespaceTypeVersion,
name
);
}
});
@ -220,74 +358,144 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry) {
* Converts migrations from a format that is convenient for callers to a format that
* is convenient for our internal usage:
* From: { type: { version: fn } }
* To: { type: { latestVersion: string, transforms: [{ version: string, transform: fn }] } }
* To: { type: { latestMigrationVersion?: string; latestCoreMigrationVersion?: string; transforms: [{ version: string, transform: fn }] } }
*/
function buildActiveMigrations(
typeRegistry: ISavedObjectTypeRegistry,
kibanaVersion: string,
log: Logger
): ActiveMigrations {
const typesWithMigrationMaps = typeRegistry
.getAllTypes()
.map((type) => ({
...type,
migrationsMap: typeof type.migrations === 'function' ? type.migrations() : type.migrations,
}))
.filter((type) => typeof type.migrationsMap !== 'undefined');
const referenceTransforms = getReferenceTransforms(typeRegistry);
typesWithMigrationMaps.forEach((type) =>
validateMigrationsMapObject(type.name, type.migrationsMap)
);
return typeRegistry.getAllTypes().reduce((migrations, type) => {
const migrationsMap =
typeof type.migrations === 'function' ? type.migrations() : type.migrations;
validateMigrationsMapObject(type.name, kibanaVersion, migrationsMap);
return typesWithMigrationMaps
.filter((type) => type.migrationsMap && Object.keys(type.migrationsMap).length > 0)
.reduce((migrations, type) => {
const transforms = Object.entries(type.migrationsMap!)
.map(([version, transform]) => ({
version,
transform: wrapWithTry(version, type.name, transform, log),
}))
.sort((a, b) => Semver.compare(a.version, b.version));
return {
...migrations,
[type.name]: {
latestVersion: _.last(transforms)!.version,
transforms,
},
};
}, {} as ActiveMigrations);
const migrationTransforms = Object.entries(migrationsMap ?? {}).map<Transform>(
([version, transform]) => ({
version,
transform: wrapWithTry(version, type.name, transform, log),
transformType: 'migrate',
})
);
const conversionTransforms = getConversionTransforms(type);
const transforms = [
...referenceTransforms,
...conversionTransforms,
...migrationTransforms,
].sort(transformComparator);
if (!transforms.length) {
return migrations;
}
const migrationVersionTransforms: Transform[] = [];
const coreMigrationVersionTransforms: Transform[] = [];
transforms.forEach((x) => {
if (x.transformType === 'migrate' || x.transformType === 'convert') {
migrationVersionTransforms.push(x);
} else {
coreMigrationVersionTransforms.push(x);
}
});
return {
...migrations,
[type.name]: {
latestMigrationVersion: _.last(migrationVersionTransforms)?.version,
latestCoreMigrationVersion: _.last(coreMigrationVersionTransforms)?.version,
transforms,
},
};
}, {} as ActiveMigrations);
}
/**
* Creates a function which migrates and validates any document that is passed to it.
*/
function buildDocumentTransform({
kibanaVersion,
migrations,
}: {
kibanaVersion: string;
migrations: ActiveMigrations;
}): TransformFn {
return function transformAndValidate(doc: SavedObjectUnsanitizedDoc) {
const result = doc.migrationVersion
? applyMigrations(doc, migrations)
: markAsUpToDate(doc, migrations);
}): ApplyTransformsFn {
return function transformAndValidate(
doc: SavedObjectUnsanitizedDoc,
options: TransformOptions = {}
) {
validateCoreMigrationVersion(doc, kibanaVersion);
const { convertNamespaceTypes = false } = options;
let transformedDoc: SavedObjectUnsanitizedDoc;
let additionalDocs: SavedObjectUnsanitizedDoc[] = [];
if (doc.migrationVersion) {
const result = applyMigrations(doc, migrations, kibanaVersion, convertNamespaceTypes);
transformedDoc = result.transformedDoc;
additionalDocs = additionalDocs.concat(
result.additionalDocs.map((x) => markAsUpToDate(x, migrations, kibanaVersion))
);
} else {
transformedDoc = markAsUpToDate(doc, migrations, kibanaVersion);
}
// In order to keep tests a bit more stable, we won't
// tack on an empy migrationVersion to docs that have
// no migrations defined.
if (_.isEmpty(result.migrationVersion)) {
delete result.migrationVersion;
if (_.isEmpty(transformedDoc.migrationVersion)) {
delete transformedDoc.migrationVersion;
}
return result;
return { transformedDoc, additionalDocs };
};
}
function applyMigrations(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations) {
function validateCoreMigrationVersion(doc: SavedObjectUnsanitizedDoc, kibanaVersion: string) {
const { id, coreMigrationVersion: docVersion } = doc;
if (!docVersion) {
return;
}
// We verify that the object's coreMigrationVersion is valid, and that it is not greater than the version supported by Kibana.
// If we have a coreMigrationVersion and the kibanaVersion is smaller than it or does not exist, we are dealing with a document that
// belongs to a future Kibana / plugin version.
if (!Semver.valid(docVersion)) {
throw Boom.badData(
`Document "${id}" has an invalid "coreMigrationVersion" [${docVersion}]. This must be a semver value.`,
doc
);
}
if (doc.coreMigrationVersion && Semver.gt(docVersion, kibanaVersion)) {
throw Boom.badData(
`Document "${id}" has a "coreMigrationVersion" which belongs to a more recent version` +
` of Kibana [${docVersion}]. The current version is [${kibanaVersion}].`,
doc
);
}
}
function applyMigrations(
doc: SavedObjectUnsanitizedDoc,
migrations: ActiveMigrations,
kibanaVersion: string,
convertNamespaceTypes: boolean
) {
let additionalDocs: SavedObjectUnsanitizedDoc[] = [];
while (true) {
const prop = nextUnmigratedProp(doc, migrations);
if (!prop) {
return doc;
// regardless of whether or not any reference transform was applied, update the coreMigrationVersion
// this is needed to ensure that newly created documents have an up-to-date coreMigrationVersion field
return {
transformedDoc: { ...doc, coreMigrationVersion: kibanaVersion },
additionalDocs,
};
}
doc = migrateProp(doc, prop, migrations);
const result = migrateProp(doc, prop, migrations, convertNamespaceTypes);
doc = result.transformedDoc;
additionalDocs = [...additionalDocs, ...result.additionalDocs];
}
}
@ -303,7 +511,7 @@ function props(doc: SavedObjectUnsanitizedDoc) {
*/
function propVersion(doc: SavedObjectUnsanitizedDoc | ActiveMigrations, prop: string) {
return (
((doc as any)[prop] && (doc as any)[prop].latestVersion) ||
((doc as any)[prop] && (doc as any)[prop].latestMigrationVersion) ||
(doc.migrationVersion && (doc as any).migrationVersion[prop])
);
}
@ -311,16 +519,137 @@ function propVersion(doc: SavedObjectUnsanitizedDoc | ActiveMigrations, prop: st
/**
* Sets the doc's migrationVersion to be the most recent version
*/
function markAsUpToDate(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations) {
function markAsUpToDate(
doc: SavedObjectUnsanitizedDoc,
migrations: ActiveMigrations,
kibanaVersion: string
) {
return {
...doc,
migrationVersion: props(doc).reduce((acc, prop) => {
const version = propVersion(migrations, prop);
return version ? set(acc, prop, version) : acc;
}, {}),
coreMigrationVersion: kibanaVersion,
};
}
/**
* Converts a single-namespace object to a multi-namespace object. This primarily entails removing the `namespace` field and adding the
* `namespaces` field.
*
* If the object does not exist in the default namespace (undefined), its ID is also regenerated, and an "originId" is added to preserve
* legacy import/copy behavior.
*/
function convertNamespaceType(doc: SavedObjectUnsanitizedDoc) {
const { namespace, ...otherAttrs } = doc;
const additionalDocs: SavedObjectUnsanitizedDoc[] = [];
// If this object exists in the default namespace, return it with the appropriate `namespaces` field without changing its ID.
if (namespace === undefined) {
return {
transformedDoc: { ...otherAttrs, namespaces: [DEFAULT_NAMESPACE_STRING] },
additionalDocs,
};
}
const { id: originId, type } = otherAttrs;
const id = deterministicallyRegenerateObjectId(namespace, type, originId!);
if (namespace !== undefined) {
const legacyUrlAlias: SavedObjectUnsanitizedDoc<LegacyUrlAlias> = {
id: `${namespace}:${type}:${originId}`,
type: LEGACY_URL_ALIAS_TYPE,
attributes: {
targetNamespace: namespace,
targetType: type,
targetId: id,
},
};
additionalDocs.push(legacyUrlAlias);
}
return {
transformedDoc: { ...otherAttrs, id, originId, namespaces: [namespace] },
additionalDocs,
};
}
/**
* Returns all applicable conversion transforms for a given object type.
*/
function getConversionTransforms(type: SavedObjectsType): Transform[] {
const { convertToMultiNamespaceTypeVersion } = type;
if (!convertToMultiNamespaceTypeVersion) {
return [];
}
return [
{
version: convertToMultiNamespaceTypeVersion,
transform: convertNamespaceType,
transformType: 'convert',
},
];
}
/**
* Returns all applicable reference transforms for all object types.
*/
function getReferenceTransforms(typeRegistry: ISavedObjectTypeRegistry): Transform[] {
const transformMap = typeRegistry
.getAllTypes()
.filter((type) => type.convertToMultiNamespaceTypeVersion)
.reduce((acc, { convertToMultiNamespaceTypeVersion: version, name }) => {
const types = acc.get(version!) ?? new Set();
return acc.set(version!, types.add(name));
}, new Map<string, Set<string>>());
return Array.from(transformMap, ([version, types]) => ({
version,
transform: (doc) => {
const { namespace, references } = doc;
if (namespace && references?.length) {
return {
transformedDoc: {
...doc,
references: references.map(({ type, id, ...attrs }) => ({
...attrs,
type,
id: types.has(type) ? deterministicallyRegenerateObjectId(namespace, type, id) : id,
})),
},
additionalDocs: [],
};
}
return { transformedDoc: doc, additionalDocs: [] };
},
transformType: 'reference',
}));
}
/**
* Transforms are sorted in ascending order by version. One version may contain multiple transforms; 'reference' transforms always run
* first, 'convert' transforms always run second, and 'migrate' transforms always run last. This is because:
* 1. 'convert' transforms get rid of the `namespace` field, which must be present for 'reference' transforms to function correctly.
* 2. 'migrate' transforms are defined by the consumer, and may change the object type or migrationVersion which resets the migration loop
* and could cause any remaining transforms for this version to be skipped.
*/
function transformComparator(a: Transform, b: Transform) {
const semver = Semver.compare(a.version, b.version);
if (semver !== 0) {
return semver;
} else if (a.transformType !== b.transformType) {
if (a.transformType === 'migrate') {
return 1;
} else if (b.transformType === 'migrate') {
return -1;
} else if (a.transformType === 'convert') {
return 1;
} else if (b.transformType === 'convert') {
return -1;
}
}
return 0;
}
/**
* If a specific transform function fails, this tacks on a bit of information
* about the document and transform that caused the failure.
@ -342,7 +671,7 @@ function wrapWithTry(
throw new Error(`Invalid saved object returned from migration ${type}:${version}.`);
}
return result;
return { transformedDoc: result, additionalDocs: [] };
} catch (error) {
const failedTransform = `${type}:${version}`;
const failedDoc = JSON.stringify(doc);
@ -354,32 +683,52 @@ function wrapWithTry(
};
}
/**
* Determines whether or not a document has any pending transforms that should be applied based on its coreMigrationVersion field.
* Currently, only reference transforms qualify.
*/
function getHasPendingCoreMigrationVersionTransform(
doc: SavedObjectUnsanitizedDoc,
migrations: ActiveMigrations,
prop: string
) {
if (!migrations.hasOwnProperty(prop)) {
return false;
}
const { latestCoreMigrationVersion } = migrations[prop];
const { coreMigrationVersion } = doc;
return (
latestCoreMigrationVersion &&
(!coreMigrationVersion || Semver.gt(latestCoreMigrationVersion, coreMigrationVersion))
);
}
/**
* Finds the first unmigrated property in the specified document.
*/
function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations) {
return props(doc).find((p) => {
const latestVersion = propVersion(migrations, p);
const latestMigrationVersion = propVersion(migrations, p);
const docVersion = propVersion(doc, p);
if (latestVersion === docVersion) {
return false;
}
// We verify that the version is not greater than the version supported by Kibana.
// If we didn't, this would cause an infinite loop, as we'd be unable to migrate the property
// but it would continue to show up as unmigrated.
// If we have a docVersion and the latestVersion is smaller than it or does not exist,
// If we have a docVersion and the latestMigrationVersion is smaller than it or does not exist,
// we are dealing with a document that belongs to a future Kibana / plugin version.
if (docVersion && (!latestVersion || Semver.gt(docVersion, latestVersion))) {
if (docVersion && (!latestMigrationVersion || Semver.gt(docVersion, latestMigrationVersion))) {
throw Boom.badData(
`Document "${doc.id}" has property "${p}" which belongs to a more recent` +
` version of Kibana [${docVersion}]. The last known version is [${latestVersion}]`,
` version of Kibana [${docVersion}]. The last known version is [${latestMigrationVersion}]`,
doc
);
}
return true;
return (
(latestMigrationVersion && latestMigrationVersion !== docVersion) ||
getHasPendingCoreMigrationVersionTransform(doc, migrations, p) // If the object itself is up-to-date, check if its references are up-to-date too
);
});
}
@ -389,23 +738,42 @@ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMi
function migrateProp(
doc: SavedObjectUnsanitizedDoc,
prop: string,
migrations: ActiveMigrations
): SavedObjectUnsanitizedDoc {
migrations: ActiveMigrations,
convertNamespaceTypes: boolean
): TransformResult {
const originalType = doc.type;
let migrationVersion = _.clone(doc.migrationVersion) || {};
const typeChanged = () => !doc.hasOwnProperty(prop) || doc.type !== originalType;
let additionalDocs: SavedObjectUnsanitizedDoc[] = [];
for (const { version, transform } of applicableTransforms(migrations, doc, prop)) {
doc = transform(doc);
migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version);
doc.migrationVersion = _.clone(migrationVersion);
for (const { version, transform, transformType } of applicableTransforms(migrations, doc, prop)) {
const currentVersion = propVersion(doc, prop);
if (currentVersion && Semver.gt(currentVersion, version)) {
// the previous transform function increased the object's migrationVersion; break out of the loop
break;
}
if (typeChanged()) {
if (convertNamespaceTypes || (transformType !== 'convert' && transformType !== 'reference')) {
// migrate transforms are always applied, but conversion transforms and reference transforms are only applied during index migrations
const result = transform(doc);
doc = result.transformedDoc;
additionalDocs = [...additionalDocs, ...result.additionalDocs];
}
if (transformType === 'reference') {
// regardless of whether or not the reference transform was applied, update the object's coreMigrationVersion
// this is needed to ensure that we don't have an endless migration loop
doc.coreMigrationVersion = version;
} else {
migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version);
doc.migrationVersion = _.clone(migrationVersion);
}
if (doc.type !== originalType) {
// the transform function changed the object's type; break out of the loop
break;
}
}
return doc;
return { transformedDoc: doc, additionalDocs };
}
/**
@ -417,9 +785,14 @@ function applicableTransforms(
prop: string
) {
const minVersion = propVersion(doc, prop);
const minReferenceVersion = doc.coreMigrationVersion || '0.0.0';
const { transforms } = migrations[prop];
return minVersion
? transforms.filter(({ version }) => Semver.gt(version, minVersion))
? transforms.filter(({ version, transformType }) =>
transformType === 'reference'
? Semver.gt(version, minReferenceVersion)
: Semver.gt(version, minVersion)
)
: transforms;
}
@ -466,3 +839,14 @@ function assertNoDowngrades(
);
}
}
/**
* Deterministically regenerates a saved object's ID based upon it's current namespace, type, and ID. This ensures that we can regenerate
* any existing object IDs without worrying about collisions if two objects that exist in different namespaces share an ID. It also ensures
* that we can later regenerate any inbound object references to match.
*
* @note This is only intended to be used when single-namespace object types are converted into multi-namespace object types.
*/
function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) {
return uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS); // the uuidv5 namespace constant (uuidv5.DNS) is arbitrary
}

View file

@ -557,6 +557,7 @@ describe('ElasticIndex', () => {
mappings,
count,
migrations,
kibanaVersion,
}: any) {
client.indices.get = jest.fn().mockReturnValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise({
@ -570,7 +571,12 @@ describe('ElasticIndex', () => {
})
);
const hasMigrations = await Index.migrationsUpToDate(client, index, migrations);
const hasMigrations = await Index.migrationsUpToDate(
client,
index,
migrations,
kibanaVersion
);
return { hasMigrations };
}
@ -584,6 +590,7 @@ describe('ElasticIndex', () => {
},
count: 0,
migrations: { dashy: '2.3.4' },
kibanaVersion: '7.10.0',
});
expect(hasMigrations).toBeFalsy();
@ -611,6 +618,7 @@ describe('ElasticIndex', () => {
},
count: 2,
migrations: {},
kibanaVersion: '7.10.0',
});
expect(hasMigrations).toBeTruthy();
@ -652,6 +660,7 @@ describe('ElasticIndex', () => {
},
count: 3,
migrations: { dashy: '23.2.5' },
kibanaVersion: '7.10.0',
});
expect(hasMigrations).toBeFalsy();
@ -677,6 +686,7 @@ describe('ElasticIndex', () => {
bashy: '99.9.3',
flashy: '3.4.5',
},
kibanaVersion: '7.10.0',
});
function shouldClause(type: string, version: string) {
@ -702,6 +712,15 @@ describe('ElasticIndex', () => {
shouldClause('dashy', '23.2.5'),
shouldClause('bashy', '99.9.3'),
shouldClause('flashy', '3.4.5'),
{
bool: {
must_not: {
term: {
coreMigrationVersion: '7.10.0',
},
},
},
},
],
},
},

View file

@ -147,6 +147,7 @@ export async function migrationsUpToDate(
client: MigrationEsClient,
index: string,
migrationVersion: SavedObjectsMigrationVersion,
kibanaVersion: string,
retryCount: number = 10
): Promise<boolean> {
try {
@ -165,18 +166,29 @@ export async function migrationsUpToDate(
body: {
query: {
bool: {
should: Object.entries(migrationVersion).map(([type, latestVersion]) => ({
bool: {
must: [
{ exists: { field: type } },
{
bool: {
must_not: { term: { [`migrationVersion.${type}`]: latestVersion } },
should: [
...Object.entries(migrationVersion).map(([type, latestVersion]) => ({
bool: {
must: [
{ exists: { field: type } },
{
bool: {
must_not: { term: { [`migrationVersion.${type}`]: latestVersion } },
},
},
],
},
})),
{
bool: {
must_not: {
term: {
coreMigrationVersion: kibanaVersion,
},
},
],
},
},
})),
],
},
},
},
@ -194,7 +206,7 @@ export async function migrationsUpToDate(
await new Promise((r) => setTimeout(r, 1000));
return await migrationsUpToDate(client, index, migrationVersion, retryCount - 1);
return await migrationsUpToDate(client, index, migrationVersion, kibanaVersion, retryCount - 1);
}
}

View file

@ -24,6 +24,7 @@ describe('IndexMigrator', () => {
batchSize: 10,
client: elasticsearchClientMock.createElasticsearchClient(),
index: '.kibana',
kibanaVersion: '7.10.0',
log: loggingSystemMock.create().get(),
mappingProperties: {},
pollInterval: 1,
@ -31,6 +32,7 @@ describe('IndexMigrator', () => {
documentMigrator: {
migrationVersion: {},
migrate: _.identity,
migrateAndConvert: _.identity,
prepareMigrations: jest.fn(),
},
serializer: new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
@ -58,6 +60,7 @@ describe('IndexMigrator', () => {
namespaces: '2f4316de49999235636386fe51dc06c1',
originId: '2f4316de49999235636386fe51dc06c1',
references: '7997cf5a56cc02bdc9c93361bde732b0',
coreMigrationVersion: '2f4316de49999235636386fe51dc06c1',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
},
@ -78,6 +81,7 @@ describe('IndexMigrator', () => {
id: { type: 'keyword' },
},
},
coreMigrationVersion: { type: 'keyword' },
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
@ -179,6 +183,7 @@ describe('IndexMigrator', () => {
namespaces: '2f4316de49999235636386fe51dc06c1',
originId: '2f4316de49999235636386fe51dc06c1',
references: '7997cf5a56cc02bdc9c93361bde732b0',
coreMigrationVersion: '2f4316de49999235636386fe51dc06c1',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
},
@ -200,6 +205,7 @@ describe('IndexMigrator', () => {
id: { type: 'keyword' },
},
},
coreMigrationVersion: { type: 'keyword' },
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
@ -240,6 +246,7 @@ describe('IndexMigrator', () => {
namespaces: '2f4316de49999235636386fe51dc06c1',
originId: '2f4316de49999235636386fe51dc06c1',
references: '7997cf5a56cc02bdc9c93361bde732b0',
coreMigrationVersion: '2f4316de49999235636386fe51dc06c1',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
},
@ -261,6 +268,7 @@ describe('IndexMigrator', () => {
id: { type: 'keyword' },
},
},
coreMigrationVersion: { type: 'keyword' },
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
@ -307,17 +315,15 @@ describe('IndexMigrator', () => {
test('transforms all docs from the original index', async () => {
let count = 0;
const { client } = testOpts;
const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => {
return {
...doc,
attributes: { name: ++count },
};
const migrateAndConvertDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => {
return [{ ...doc, attributes: { name: ++count } }];
});
testOpts.documentMigrator = {
migrationVersion: { foo: '1.2.3' },
migrate: jest.fn(),
migrateAndConvert: migrateAndConvertDoc,
prepareMigrations: jest.fn(),
migrate: migrateDoc,
};
withIndex(client, {
@ -331,14 +337,14 @@ describe('IndexMigrator', () => {
await new IndexMigrator(testOpts).migrate();
expect(count).toEqual(2);
expect(migrateDoc).toHaveBeenCalledWith({
expect(migrateAndConvertDoc).toHaveBeenNthCalledWith(1, {
id: '1',
type: 'foo',
attributes: { name: 'Bar' },
migrationVersion: {},
references: [],
});
expect(migrateDoc).toHaveBeenCalledWith({
expect(migrateAndConvertDoc).toHaveBeenNthCalledWith(2, {
id: '2',
type: 'foo',
attributes: { name: 'Baz' },
@ -363,14 +369,15 @@ describe('IndexMigrator', () => {
test('rejects when the migration function throws an error', async () => {
const { client } = testOpts;
const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => {
const migrateAndConvertDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => {
throw new Error('error migrating document');
});
testOpts.documentMigrator = {
migrationVersion: { foo: '1.2.3' },
migrate: jest.fn(),
migrateAndConvert: migrateAndConvertDoc,
prepareMigrations: jest.fn(),
migrate: migrateDoc,
};
withIndex(client, {

View file

@ -60,13 +60,14 @@ export class IndexMigrator {
* Determines what action the migration system needs to take (none, patch, migrate).
*/
async function requiresMigration(context: Context): Promise<boolean> {
const { client, alias, documentMigrator, dest, log } = context;
const { client, alias, documentMigrator, dest, kibanaVersion, log } = context;
// Have all of our known migrations been run against the index?
const hasMigrations = await Index.migrationsUpToDate(
client,
alias,
documentMigrator.migrationVersion
documentMigrator.migrationVersion,
kibanaVersion
);
if (!hasMigrations) {
@ -184,7 +185,7 @@ async function migrateSourceToDest(context: Context) {
await Index.write(
client,
dest.indexName,
await migrateRawDocs(serializer, documentMigrator.migrate, docs, log)
await migrateRawDocs(serializer, documentMigrator.migrateAndConvert, docs, log)
);
}
}

View file

@ -15,7 +15,9 @@ import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks';
describe('migrateRawDocs', () => {
test('converts raw docs to saved objects', async () => {
const transform = jest.fn<any, any>((doc: any) => set(doc, 'attributes.name', 'HOI!'));
const transform = jest.fn<any, any>((doc: any) => [
set(_.cloneDeep(doc), 'attributes.name', 'HOI!'),
]);
const result = await migrateRawDocs(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
transform,
@ -37,14 +39,30 @@ describe('migrateRawDocs', () => {
},
]);
expect(transform).toHaveBeenCalled();
const obj1 = {
id: 'b',
type: 'a',
attributes: { name: 'AAA' },
migrationVersion: {},
references: [],
};
const obj2 = {
id: 'd',
type: 'c',
attributes: { name: 'DDD' },
migrationVersion: {},
references: [],
};
expect(transform).toHaveBeenCalledTimes(2);
expect(transform).toHaveBeenNthCalledWith(1, obj1);
expect(transform).toHaveBeenNthCalledWith(2, obj2);
});
test('passes invalid docs through untouched and logs error', async () => {
const logger = createSavedObjectsMigrationLoggerMock();
const transform = jest.fn<any, any>((doc: any) =>
set(_.cloneDeep(doc), 'attributes.name', 'TADA')
);
const transform = jest.fn<any, any>((doc: any) => [
set(_.cloneDeep(doc), 'attributes.name', 'TADA'),
]);
const result = await migrateRawDocs(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
transform,
@ -63,23 +81,53 @@ describe('migrateRawDocs', () => {
},
]);
expect(transform.mock.calls).toEqual([
[
{
id: 'd',
type: 'c',
attributes: {
name: 'DDD',
},
migrationVersion: {},
references: [],
},
],
]);
const obj2 = {
id: 'd',
type: 'c',
attributes: { name: 'DDD' },
migrationVersion: {},
references: [],
};
expect(transform).toHaveBeenCalledTimes(1);
expect(transform).toHaveBeenCalledWith(obj2);
expect(logger.error).toBeCalledTimes(1);
});
test('handles when one document is transformed into multiple documents', async () => {
const transform = jest.fn<any, any>((doc: any) => [
set(_.cloneDeep(doc), 'attributes.name', 'HOI!'),
{ id: 'bar', type: 'foo', attributes: { name: 'baz' } },
]);
const result = await migrateRawDocs(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
transform,
[{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }],
createSavedObjectsMigrationLoggerMock()
);
expect(result).toEqual([
{
_id: 'a:b',
_source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] },
},
{
_id: 'foo:bar',
_source: { type: 'foo', foo: { name: 'baz' }, references: [] },
},
]);
const obj = {
id: 'b',
type: 'a',
attributes: { name: 'AAA' },
migrationVersion: {},
references: [],
};
expect(transform).toHaveBeenCalledTimes(1);
expect(transform).toHaveBeenCalledWith(obj);
});
test('rejects when the transform function throws an error', async () => {
const transform = jest.fn<any, any>((doc: any) => {
throw new Error('error during transform');

View file

@ -15,7 +15,7 @@ import {
SavedObjectsSerializer,
SavedObjectUnsanitizedDoc,
} from '../../serialization';
import { TransformFn } from './document_migrator';
import { MigrateAndConvertFn } from './document_migrator';
import { SavedObjectsMigrationLogger } from '.';
/**
@ -28,21 +28,24 @@ import { SavedObjectsMigrationLogger } from '.';
*/
export async function migrateRawDocs(
serializer: SavedObjectsSerializer,
migrateDoc: TransformFn,
migrateDoc: MigrateAndConvertFn,
rawDocs: SavedObjectsRawDoc[],
log: SavedObjectsMigrationLogger
): Promise<SavedObjectsRawDoc[]> {
const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc);
const processedDocs = [];
for (const raw of rawDocs) {
if (serializer.isRawSavedObject(raw)) {
const savedObject = serializer.rawToSavedObject(raw);
const options = { namespaceTreatment: 'lax' as 'lax' };
if (serializer.isRawSavedObject(raw, options)) {
const savedObject = serializer.rawToSavedObject(raw, options);
savedObject.migrationVersion = savedObject.migrationVersion || {};
processedDocs.push(
serializer.savedObjectToRaw({
references: [],
...(await migrateDocWithoutBlocking(savedObject)),
})
...(await migrateDocWithoutBlocking(savedObject)).map((attrs) =>
serializer.savedObjectToRaw({
references: [],
...attrs,
})
)
);
} else {
log.error(
@ -63,8 +66,8 @@ export async function migrateRawDocs(
* work in between each transform.
*/
function transformNonBlocking(
transform: TransformFn
): (doc: SavedObjectUnsanitizedDoc) => Promise<SavedObjectUnsanitizedDoc> {
transform: MigrateAndConvertFn
): (doc: SavedObjectUnsanitizedDoc) => Promise<SavedObjectUnsanitizedDoc[]> {
// promises aren't enough to unblock the event loop
return (doc: SavedObjectUnsanitizedDoc) =>
new Promise((resolve, reject) => {

View file

@ -32,6 +32,7 @@ export interface MigrationOpts {
scrollDuration: string;
client: MigrationEsClient;
index: string;
kibanaVersion: string;
log: Logger;
mappingProperties: SavedObjectsTypeMappingDefinitions;
documentMigrator: VersionedTransformer;
@ -54,6 +55,7 @@ export interface Context {
source: Index.FullIndexInfo;
dest: Index.FullIndexInfo;
documentMigrator: VersionedTransformer;
kibanaVersion: string;
log: SavedObjectsMigrationLogger;
batchSize: number;
pollInterval: number;
@ -78,6 +80,7 @@ export async function migrationContext(opts: MigrationOpts): Promise<Context> {
alias,
source,
dest,
kibanaVersion: opts.kibanaVersion,
log: new MigrationLogger(log),
batchSize: opts.batchSize,
documentMigrator: opts.documentMigrator,

View file

@ -6,6 +6,7 @@ Object {
"migrationMappingPropertyHashes": Object {
"amap": "510f1f0adb69830cf8a1c5ce2923ed82",
"bmap": "510f1f0adb69830cf8a1c5ce2923ed82",
"coreMigrationVersion": "2f4316de49999235636386fe51dc06c1",
"migrationVersion": "4a1746014a75ade3a714e1db5763276f",
"namespace": "2f4316de49999235636386fe51dc06c1",
"namespaces": "2f4316de49999235636386fe51dc06c1",
@ -31,6 +32,9 @@ Object {
},
},
},
"coreMigrationVersion": Object {
"type": "keyword",
},
"migrationVersion": Object {
"dynamic": "true",
"type": "object",

View file

@ -90,6 +90,7 @@ export class KibanaMigrator {
}: KibanaMigratorOptions) {
this.client = client;
this.kibanaConfig = kibanaConfig;
this.kibanaVersion = kibanaVersion;
this.savedObjectsConfig = savedObjectsConfig;
this.typeRegistry = typeRegistry;
this.serializer = new SavedObjectsSerializer(this.typeRegistry);
@ -177,7 +178,7 @@ export class KibanaMigrator {
transformRawDocs: (rawDocs: SavedObjectsRawDoc[]) =>
migrateRawDocs(
this.serializer,
this.documentMigrator.migrate,
this.documentMigrator.migrateAndConvert,
rawDocs,
new MigrationLogger(this.log)
),
@ -192,6 +193,7 @@ export class KibanaMigrator {
client: createMigrationEsClient(this.client, this.log, this.migrationsRetryDelay),
documentMigrator: this.documentMigrator,
index,
kibanaVersion: this.kibanaVersion,
log: this.log,
mappingProperties: indexMap[index].typeMappings,
pollInterval: this.savedObjectsConfig.pollInterval,

View file

@ -61,7 +61,7 @@ export interface SavedObjectMigrationContext {
/**
* A map of {@link SavedObjectMigrationFn | migration functions} to be used for a given type.
* The map's keys must be valid semver versions.
* The map's keys must be valid semver versions, and they cannot exceed the current Kibana version.
*
* For a given document, only migrations with a higher version number than that of the document will be applied.
* Migrations are executed in order, starting from the lowest version and ending with the highest one.

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
/**
* @internal
*/
export const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias';

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
export { LEGACY_URL_ALIAS_TYPE } from './constants';
export { LegacyUrlAlias } from './types';
export { registerCoreObjectTypes } from './registration';

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { typeRegistryMock } from '../saved_objects_type_registry.mock';
import { LEGACY_URL_ALIAS_TYPE } from './constants';
import { registerCoreObjectTypes } from './registration';
describe('Core saved object types registration', () => {
describe('#registerCoreObjectTypes', () => {
it('registers all expected types', () => {
const typeRegistry = typeRegistryMock.create();
registerCoreObjectTypes(typeRegistry);
expect(typeRegistry.registerType).toHaveBeenCalledTimes(1);
expect(typeRegistry.registerType).toHaveBeenCalledWith(
expect.objectContaining({ name: LEGACY_URL_ALIAS_TYPE })
);
});
});
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { LEGACY_URL_ALIAS_TYPE } from './constants';
import { ISavedObjectTypeRegistry, SavedObjectsType, SavedObjectTypeRegistry } from '..';
const legacyUrlAliasType: SavedObjectsType = {
name: LEGACY_URL_ALIAS_TYPE,
namespaceType: 'agnostic',
mappings: {
dynamic: false, // we aren't querying or aggregating over this data, so we don't need to specify any fields
properties: {},
},
hidden: true,
};
/**
* @internal
*/
export function registerCoreObjectTypes(
typeRegistry: ISavedObjectTypeRegistry & Pick<SavedObjectTypeRegistry, 'registerType'>
) {
typeRegistry.registerType(legacyUrlAliasType);
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
/**
* @internal
*/
export interface LegacyUrlAlias {
targetNamespace: string;
targetType: string;
targetId: string;
lastResolved?: string;
resolveCounter?: number;
disabled?: boolean;
}

View file

@ -29,6 +29,7 @@ export const registerBulkCreateRoute = (router: IRouter, { coreUsageData }: Rout
attributes: schema.recordOf(schema.string(), schema.any()),
version: schema.maybe(schema.string()),
migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())),
coreMigrationVersion: schema.maybe(schema.string()),
references: schema.maybe(
schema.arrayOf(
schema.object({

View file

@ -29,6 +29,7 @@ export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDep
body: schema.object({
attributes: schema.recordOf(schema.string(), schema.any()),
migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())),
coreMigrationVersion: schema.maybe(schema.string()),
references: schema.maybe(
schema.arrayOf(
schema.object({
@ -45,12 +46,25 @@ export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDep
router.handleLegacyErrors(async (context, req, res) => {
const { type, id } = req.params;
const { overwrite } = req.query;
const { attributes, migrationVersion, references, initialNamespaces } = req.body;
const {
attributes,
migrationVersion,
coreMigrationVersion,
references,
initialNamespaces,
} = req.body;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsCreate({ request: req }).catch(() => {});
const options = { id, overwrite, migrationVersion, references, initialNamespaces };
const options = {
id,
overwrite,
migrationVersion,
coreMigrationVersion,
references,
initialNamespaces,
};
const result = await context.core.savedObjects.client.create(type, attributes, options);
return res.ok({ body: result });
})

View file

@ -12,6 +12,7 @@ import { Logger } from '../../logging';
import { SavedObjectConfig } from '../saved_objects_config';
import { IKibanaMigrator } from '../migrations';
import { registerGetRoute } from './get';
import { registerResolveRoute } from './resolve';
import { registerCreateRoute } from './create';
import { registerDeleteRoute } from './delete';
import { registerFindRoute } from './find';
@ -41,6 +42,7 @@ export function registerRoutes({
const router = http.createRouter('/api/saved_objects/');
registerGetRoute(router, { coreUsageData });
registerResolveRoute(router, { coreUsageData });
registerCreateRoute(router, { coreUsageData });
registerDeleteRoute(router, { coreUsageData });
registerFindRoute(router, { coreUsageData });

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import supertest from 'supertest';
import { registerResolveRoute } from '../resolve';
import { ContextService } from '../../../context';
import { savedObjectsClientMock } from '../../service/saved_objects_client.mock';
import { CoreUsageStatsClient } from '../../../core_usage_data';
import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock';
import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock';
import { HttpService, InternalHttpServiceSetup } from '../../../http';
import { createHttpServer, createCoreContext } from '../../../http/test_utils';
import { coreMock } from '../../../mocks';
const coreId = Symbol('core');
describe('GET /api/saved_objects/resolve/{type}/{id}', () => {
let server: HttpService;
let httpSetup: InternalHttpServiceSetup;
let handlerContext: ReturnType<typeof coreMock.createRequestHandlerContext>;
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
let coreUsageStatsClient: jest.Mocked<CoreUsageStatsClient>;
beforeEach(async () => {
const coreContext = createCoreContext({ coreId });
server = createHttpServer(coreContext);
const contextService = new ContextService(coreContext);
httpSetup = await server.setup({
context: contextService.setup({ pluginDependencies: new Map() }),
});
handlerContext = coreMock.createRequestHandlerContext();
savedObjectsClient = handlerContext.savedObjects.client;
httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => {
return handlerContext;
});
const router = httpSetup.createRouter('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
coreUsageStatsClient.incrementSavedObjectsResolve.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerResolveRoute(router, { coreUsageData });
await server.start();
});
afterEach(async () => {
await server.stop();
});
it('formats successful response', async () => {
const clientResponse = {
saved_object: {
id: 'logstash-*',
title: 'logstash-*',
type: 'logstash-type',
attributes: {},
timeFieldName: '@timestamp',
notExpandable: true,
references: [],
},
outcome: 'exactMatch' as 'exactMatch',
};
savedObjectsClient.resolve.mockResolvedValue(clientResponse);
const result = await supertest(httpSetup.server.listener)
.get('/api/saved_objects/resolve/index-pattern/logstash-*')
.expect(200);
expect(result.body).toEqual(clientResponse);
});
it('calls upon savedObjectClient.resolve', async () => {
await supertest(httpSetup.server.listener)
.get('/api/saved_objects/resolve/index-pattern/logstash-*')
.expect(200);
expect(savedObjectsClient.resolve).toHaveBeenCalled();
const args = savedObjectsClient.resolve.mock.calls[0];
expect(args).toEqual(['index-pattern', 'logstash-*']);
});
});

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { IRouter } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
interface RouteDependencies {
coreUsageData: CoreUsageDataSetup;
}
export const registerResolveRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => {
router.get(
{
path: '/resolve/{type}/{id}',
validate: {
params: schema.object({
type: schema.string(),
id: schema.string(),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const { type, id } = req.params;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsResolve({ request: req }).catch(() => {});
const result = await context.core.savedObjects.client.resolve(type, id);
return res.ok({ body: result });
})
);
};

View file

@ -25,8 +25,10 @@ import { httpServerMock } from '../http/http_server.mocks';
import { SavedObjectsClientFactoryProvider } from './service/lib';
import { NodesVersionCompatibility } from '../elasticsearch/version_check/ensure_es_version';
import { SavedObjectsRepository } from './service/lib/repository';
import { registerCoreObjectTypes } from './object_types';
jest.mock('./service/lib/repository');
jest.mock('./object_types');
describe('SavedObjectsService', () => {
const createCoreContext = ({
@ -67,6 +69,16 @@ describe('SavedObjectsService', () => {
});
describe('#setup()', () => {
it('calls registerCoreObjectTypes', async () => {
const coreContext = createCoreContext();
const soService = new SavedObjectsService(coreContext);
const mockedRegisterCoreObjectTypes = registerCoreObjectTypes as jest.Mock<any, any>;
expect(mockedRegisterCoreObjectTypes).not.toHaveBeenCalled();
await soService.setup(createSetupDeps());
expect(mockedRegisterCoreObjectTypes).toHaveBeenCalledTimes(1);
});
describe('#setClientFactoryProvider', () => {
it('registers the factory to the clientProvider', async () => {
const coreContext = createCoreContext();
@ -130,6 +142,7 @@ describe('SavedObjectsService', () => {
describe('#registerType', () => {
it('registers the type to the internal typeRegistry', async () => {
// we mocked registerCoreObjectTypes above, so this test case only reflects direct calls to the registerType method
const coreContext = createCoreContext();
const soService = new SavedObjectsService(coreContext);
const setup = await soService.setup(createSetupDeps());

View file

@ -43,6 +43,7 @@ import { SavedObjectsImporter, ISavedObjectsImporter } from './import';
import { registerRoutes } from './routes';
import { ServiceStatus } from '../status';
import { calculateStatus$ } from './status';
import { registerCoreObjectTypes } from './object_types';
/**
* Saved Objects is Kibana's data persistence mechanism allowing plugins to
@ -305,6 +306,8 @@ export class SavedObjectsService
migratorPromise: this.migrator$.pipe(first()).toPromise(),
});
registerCoreObjectTypes(this.typeRegistry);
return {
status$: calculateStatus$(
this.migrator$.pipe(switchMap((migrator) => migrator.getStatus$())),

View file

@ -15,6 +15,7 @@ export {
SavedObjectUnsanitizedDoc,
SavedObjectSanitizedDoc,
SavedObjectsRawDoc,
SavedObjectsRawDocParseOptions,
SavedObjectsRawDocSource,
} from './types';
export { SavedObjectsSerializer } from './serializer';

View file

@ -11,6 +11,7 @@ import { SavedObjectsSerializer } from './serializer';
import { SavedObjectsRawDoc } from './types';
import { typeRegistryMock } from '../saved_objects_type_registry.mock';
import { encodeVersion } from '../version';
import { LEGACY_URL_ALIAS_TYPE } from '../object_types';
let typeRegistry = typeRegistryMock.create();
typeRegistry.isNamespaceAgnostic.mockReturnValue(true);
@ -131,6 +132,27 @@ describe('#rawToSavedObject', () => {
expect(expected).toEqual(actual);
});
test('if specified it copies the _source.coreMigrationVersion property to coreMigrationVersion', () => {
const actual = singleNamespaceSerializer.rawToSavedObject({
_id: 'foo:bar',
_source: {
type: 'foo',
coreMigrationVersion: '1.2.3',
},
});
expect(actual).toHaveProperty('coreMigrationVersion', '1.2.3');
});
test(`if _source.coreMigrationVersion is unspecified it doesn't set coreMigrationVersion`, () => {
const actual = singleNamespaceSerializer.rawToSavedObject({
_id: 'foo:bar',
_source: {
type: 'foo',
},
});
expect(actual).not.toHaveProperty('coreMigrationVersion');
});
test(`if version is unspecified it doesn't set version`, () => {
const actual = singleNamespaceSerializer.rawToSavedObject({
_id: 'foo:bar',
@ -288,6 +310,7 @@ describe('#rawToSavedObject', () => {
foo: '1.2.3',
bar: '9.8.7',
},
coreMigrationVersion: '4.5.6',
namespace: 'foo-namespace',
updated_at: String(new Date()),
references: [],
@ -412,6 +435,41 @@ describe('#rawToSavedObject', () => {
test(`doesn't copy _source.namespace to namespace`, () => {
expect(actual).not.toHaveProperty('namespace');
});
describe('with lax namespaceTreatment', () => {
const options = { namespaceTreatment: 'lax' as 'lax' };
test(`removes type prefix from _id and, and does not copy _source.namespace to namespace`, () => {
const _actual = multiNamespaceSerializer.rawToSavedObject(raw, options);
expect(_actual).toHaveProperty('id', 'bar');
expect(_actual).not.toHaveProperty('namespace');
});
test(`removes type and namespace prefix from _id, and copies _source.namespace to namespace`, () => {
const _id = `${raw._source.namespace}:${raw._id}`;
const _actual = multiNamespaceSerializer.rawToSavedObject({ ...raw, _id }, options);
expect(_actual).toHaveProperty('id', 'bar');
expect(_actual).toHaveProperty('namespace', raw._source.namespace); // "baz"
});
test(`removes type and namespace prefix from _id when the namespace matches the type`, () => {
const _raw = createSampleDoc({ _id: 'foo:foo:bar', _source: { namespace: 'foo' } });
const _actual = multiNamespaceSerializer.rawToSavedObject(_raw, options);
expect(_actual).toHaveProperty('id', 'bar');
expect(_actual).toHaveProperty('namespace', 'foo');
});
test(`does not remove the entire _id when the namespace matches the type`, () => {
// This is not a realistic/valid document, but we defensively check to ensure we aren't trimming the entire ID.
// In this test case, a multi-namespace document has a raw ID with the type prefix "foo:" and an object ID of "foo:" (no namespace
// prefix). This document *also* has a `namespace` field the same as the type, while it should not have a `namespace` field at all
// since it has no namespace prefix in its raw ID.
const _raw = createSampleDoc({ _id: 'foo:foo:', _source: { namespace: 'foo' } });
const _actual = multiNamespaceSerializer.rawToSavedObject(_raw, options);
expect(_actual).toHaveProperty('id', 'foo:');
expect(_actual).not.toHaveProperty('namespace');
});
});
});
describe('multi-namespace type with namespaces', () => {
@ -515,6 +573,25 @@ describe('#savedObjectToRaw', () => {
expect(actual._source).not.toHaveProperty('migrationVersion');
});
test('it copies the coreMigrationVersion property to _source.coreMigrationVersion', () => {
const actual = singleNamespaceSerializer.savedObjectToRaw({
type: '',
attributes: {},
coreMigrationVersion: '1.2.3',
} as any);
expect(actual._source).toHaveProperty('coreMigrationVersion', '1.2.3');
});
test(`if unspecified it doesn't add coreMigrationVersion property to _source`, () => {
const actual = singleNamespaceSerializer.savedObjectToRaw({
type: '',
attributes: {},
} as any);
expect(actual._source).not.toHaveProperty('coreMigrationVersion');
});
test('it decodes the version property to _seq_no and _primary_term', () => {
const actual = singleNamespaceSerializer.savedObjectToRaw({
type: '',
@ -841,6 +918,116 @@ describe('#isRawSavedObject', () => {
});
});
describe('multi-namespace type with a namespace', () => {
test('is true if the id is prefixed with type and the type matches', () => {
expect(
multiNamespaceSerializer.isRawSavedObject({
_id: 'hello:world',
_source: {
type: 'hello',
hello: {},
namespace: 'foo',
},
})
).toBeTruthy();
});
test('is false if the id is not prefixed', () => {
expect(
multiNamespaceSerializer.isRawSavedObject({
_id: 'world',
_source: {
type: 'hello',
hello: {},
namespace: 'foo',
},
})
).toBeFalsy();
});
test('is false if the id is prefixed with type and namespace', () => {
expect(
multiNamespaceSerializer.isRawSavedObject({
_id: 'foo:hello:world',
_source: {
type: 'hello',
hello: {},
namespace: 'foo',
},
})
).toBeFalsy();
});
test('is true if the id is prefixed with type and namespace, and namespaceTreatment is lax', () => {
const options = { namespaceTreatment: 'lax' as 'lax' };
expect(
multiNamespaceSerializer.isRawSavedObject(
{
_id: 'foo:hello:world',
_source: {
type: 'hello',
hello: {},
namespace: 'foo',
},
},
options
)
).toBeTruthy();
});
test(`is false if the type prefix omits the :`, () => {
expect(
namespaceAgnosticSerializer.isRawSavedObject({
_id: 'helloworld',
_source: {
type: 'hello',
hello: {},
namespace: 'foo',
},
})
).toBeFalsy();
});
test('is false if the type attribute is missing', () => {
expect(
multiNamespaceSerializer.isRawSavedObject({
_id: 'hello:world',
_source: {
hello: {},
namespace: 'foo',
} as any,
})
).toBeFalsy();
});
test('is false if the type attribute does not match the id', () => {
expect(
multiNamespaceSerializer.isRawSavedObject({
_id: 'hello:world',
_source: {
type: 'jam',
jam: {},
hello: {},
namespace: 'foo',
},
})
).toBeFalsy();
});
test('is false if there is no [type] attribute', () => {
expect(
multiNamespaceSerializer.isRawSavedObject({
_id: 'hello:world',
_source: {
type: 'hello',
jam: {},
namespace: 'foo',
},
})
).toBeFalsy();
});
});
describe('namespace-agnostic type with a namespace', () => {
test('is true if the id is prefixed with type and the type matches', () => {
expect(
@ -950,6 +1137,13 @@ describe('#generateRawId', () => {
});
});
describe('multi-namespace type with a namespace', () => {
test(`uses the id that is specified and doesn't prefix the namespace`, () => {
const id = multiNamespaceSerializer.generateRawId('foo', 'hello', 'world');
expect(id).toEqual('hello:world');
});
});
describe('namespace-agnostic type with a namespace', () => {
test(`uses the id that is specified and doesn't prefix the namespace`, () => {
const id = namespaceAgnosticSerializer.generateRawId('foo', 'hello', 'world');
@ -957,3 +1151,24 @@ describe('#generateRawId', () => {
});
});
});
describe('#generateRawLegacyUrlAliasId', () => {
describe(`returns expected value`, () => {
const expected = `${LEGACY_URL_ALIAS_TYPE}:foo:bar:baz`;
test(`for single-namespace types`, () => {
const id = singleNamespaceSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz');
expect(id).toEqual(expected);
});
test(`for multi-namespace types`, () => {
const id = multiNamespaceSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz');
expect(id).toEqual(expected);
});
test(`for namespace-agnostic types`, () => {
const id = namespaceAgnosticSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz');
expect(id).toEqual(expected);
});
});
});

View file

@ -6,9 +6,14 @@
* Public License, v 1.
*/
import { LEGACY_URL_ALIAS_TYPE } from '../object_types';
import { decodeVersion, encodeVersion } from '../version';
import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry';
import { SavedObjectsRawDoc, SavedObjectSanitizedDoc } from './types';
import {
SavedObjectsRawDoc,
SavedObjectSanitizedDoc,
SavedObjectsRawDocParseOptions,
} from './types';
/**
* A serializer that can be used to manually convert {@link SavedObjectsRawDoc | raw} or
@ -30,42 +35,60 @@ export class SavedObjectsSerializer {
/**
* Determines whether or not the raw document can be converted to a saved object.
*
* @param {SavedObjectsRawDoc} rawDoc - The raw ES document to be tested
* @param {SavedObjectsRawDoc} doc - The raw ES document to be tested
* @param {SavedObjectsRawDocParseOptions} options - Options for parsing the raw document.
*/
public isRawSavedObject(rawDoc: SavedObjectsRawDoc) {
const { type, namespace } = rawDoc._source;
const namespacePrefix =
namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : '';
return Boolean(
type &&
rawDoc._id.startsWith(`${namespacePrefix}${type}:`) &&
rawDoc._source.hasOwnProperty(type)
);
public isRawSavedObject(doc: SavedObjectsRawDoc, options: SavedObjectsRawDocParseOptions = {}) {
const { namespaceTreatment = 'strict' } = options;
const { _id, _source } = doc;
const { type, namespace } = _source;
if (!type) {
return false;
}
const { idMatchesPrefix } = this.parseIdPrefix(namespace, type, _id, namespaceTreatment);
return idMatchesPrefix && _source.hasOwnProperty(type);
}
/**
* Converts a document from the format that is stored in elasticsearch to the saved object client format.
*
* @param {SavedObjectsRawDoc} doc - The raw ES document to be converted to saved object format.
* @param {SavedObjectsRawDoc} doc - The raw ES document to be converted to saved object format.
* @param {SavedObjectsRawDocParseOptions} options - Options for parsing the raw document.
*/
public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc {
public rawToSavedObject(
doc: SavedObjectsRawDoc,
options: SavedObjectsRawDocParseOptions = {}
): SavedObjectSanitizedDoc {
const { namespaceTreatment = 'strict' } = options;
const { _id, _source, _seq_no, _primary_term } = doc;
const { type, namespace, namespaces, originId } = _source;
const {
type,
namespaces,
originId,
migrationVersion,
references,
coreMigrationVersion,
} = _source;
const version =
_seq_no != null || _primary_term != null
? encodeVersion(_seq_no!, _primary_term!)
: undefined;
const { id, namespace } = this.trimIdPrefix(_source.namespace, type, _id, namespaceTreatment);
const includeNamespace =
namespace && (namespaceTreatment === 'lax' || this.registry.isSingleNamespace(type));
const includeNamespaces = this.registry.isMultiNamespace(type);
return {
type,
id: this.trimIdPrefix(namespace, type, _id),
...(namespace && this.registry.isSingleNamespace(type) && { namespace }),
...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }),
id,
...(includeNamespace && { namespace }),
...(includeNamespaces && { namespaces }),
...(originId && { originId }),
attributes: _source[type],
references: _source.references || [],
...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }),
references: references || [],
...(migrationVersion && { migrationVersion }),
...(coreMigrationVersion && { coreMigrationVersion }),
...(_source.updated_at && { updated_at: _source.updated_at }),
...(version && { version }),
};
@ -89,6 +112,7 @@ export class SavedObjectsSerializer {
updated_at,
version,
references,
coreMigrationVersion,
} = savedObj;
const source = {
[type]: attributes,
@ -98,6 +122,7 @@ export class SavedObjectsSerializer {
...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }),
...(originId && { originId }),
...(migrationVersion && { migrationVersion }),
...(coreMigrationVersion && { coreMigrationVersion }),
...(updated_at && { updated_at }),
};
@ -121,22 +146,77 @@ export class SavedObjectsSerializer {
return `${namespacePrefix}${type}:${id}`;
}
private trimIdPrefix(namespace: string | undefined, type: string, id: string) {
/**
* Given a saved object type and id, generates the compound id that is stored in the raw document for its legacy URL alias.
*
* @param {string} namespace - The namespace of the saved object
* @param {string} type - The saved object type
* @param {string} id - The id of the saved object
*/
public generateRawLegacyUrlAliasId(namespace: string, type: string, id: string) {
return `${LEGACY_URL_ALIAS_TYPE}:${namespace}:${type}:${id}`;
}
/**
* Given a document's source namespace, type, and raw ID, trim the ID prefix (based on the namespaceType), returning the object ID and the
* detected namespace. A single-namespace object is only considered to exist in a namespace if its raw ID is prefixed by that *and* it has
* the namespace field in its source.
*/
private trimIdPrefix(
sourceNamespace: string | undefined,
type: string,
id: string,
namespaceTreatment: 'strict' | 'lax'
) {
assertNonEmptyString(id, 'document id');
assertNonEmptyString(type, 'saved object type');
const namespacePrefix =
namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : '';
const prefix = `${namespacePrefix}${type}:`;
const { prefix, idMatchesPrefix, namespace } = this.parseIdPrefix(
sourceNamespace,
type,
id,
namespaceTreatment
);
return {
id: idMatchesPrefix ? id.slice(prefix.length) : id,
namespace,
};
}
if (!id.startsWith(prefix)) {
return id;
private parseIdPrefix(
sourceNamespace: string | undefined,
type: string,
id: string,
namespaceTreatment: 'strict' | 'lax'
) {
let prefix: string; // the prefix that is used to validate this raw object ID
let namespace: string | undefined; // the namespace that is in the raw object ID (only for single-namespace objects)
const parseFlexibly = namespaceTreatment === 'lax' && this.registry.isMultiNamespace(type);
if (sourceNamespace && (this.registry.isSingleNamespace(type) || parseFlexibly)) {
prefix = `${sourceNamespace}:${type}:`;
if (parseFlexibly && !checkIdMatchesPrefix(id, prefix)) {
prefix = `${type}:`;
} else {
// this is either a single-namespace object, or is being converted into a multi-namespace object
namespace = sourceNamespace;
}
} else {
// there is no source namespace, OR there is a source namespace but this is not a single-namespace object
prefix = `${type}:`;
}
return id.slice(prefix.length);
return {
prefix,
idMatchesPrefix: checkIdMatchesPrefix(id, prefix),
namespace,
};
}
}
function checkIdMatchesPrefix(id: string, prefix: string) {
return id.startsWith(prefix) && id.length > prefix.length;
}
function assertNonEmptyString(value: string, name: string) {
if (!value || typeof value !== 'string') {
throw new TypeError(`Expected "${value}" to be a ${name}`);

View file

@ -43,6 +43,7 @@ interface SavedObjectDoc<T = unknown> {
namespace?: string;
namespaces?: string[];
migrationVersion?: SavedObjectsMigrationVersion;
coreMigrationVersion?: string;
version?: string;
updated_at?: string;
originId?: string;
@ -68,3 +69,19 @@ export type SavedObjectUnsanitizedDoc<T = unknown> = SavedObjectDoc<T> & Partial
* @public
*/
export type SavedObjectSanitizedDoc<T = unknown> = SavedObjectDoc<T> & Referencable;
/**
* Options that can be specified when using the saved objects serializer to parse a raw document.
*
* @public
*/
export interface SavedObjectsRawDocParseOptions {
/**
* Optional setting to allow for lax handling of the raw document ID and namespace field. This is needed when a previously
* single-namespace object type is converted to a multi-namespace object type, and it is only intended to be used during upgrade
* migrations.
*
* If not specified, the default treatment is `strict`.
*/
namespaceTreatment?: 'strict' | 'lax';
}

View file

@ -8,7 +8,7 @@
import { includedFields } from './included_fields';
const BASE_FIELD_COUNT = 9;
const BASE_FIELD_COUNT = 10;
describe('includedFields', () => {
it('returns undefined if fields are not provided', () => {
@ -32,6 +32,7 @@ Array [
"type",
"references",
"migrationVersion",
"coreMigrationVersion",
"updated_at",
"originId",
"foo",
@ -66,6 +67,7 @@ Array [
"type",
"references",
"migrationVersion",
"coreMigrationVersion",
"updated_at",
"originId",
"foo",

View file

@ -30,6 +30,7 @@ export function includedFields(type: string | string[] = '*', fields?: string[]
.concat('type')
.concat('references')
.concat('migrationVersion')
.concat('coreMigrationVersion')
.concat('updated_at')
.concat('originId')
.concat(fields); // v5 compatibility

View file

@ -17,6 +17,7 @@ const create = (): jest.Mocked<ISavedObjectsRepository> => ({
bulkGet: jest.fn(),
find: jest.fn(),
get: jest.fn(),
resolve: jest.fn(),
update: jest.fn(),
addToNamespaces: jest.fn(),
deleteFromNamespaces: jest.fn(),

View file

@ -13,6 +13,7 @@ import { ALL_NAMESPACES_STRING } from './utils';
import { SavedObjectsSerializer } from '../../serialization';
import { encodeHitVersion } from '../../version';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { LEGACY_URL_ALIAS_TYPE } from '../../object_types';
import { DocumentMigrator } from '../../migrations/core/document_migrator';
import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock';
import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks';
@ -44,6 +45,7 @@ describe('SavedObjectsRepository', () => {
const mockVersionProps = { _seq_no: 1, _primary_term: 1 };
const mockVersion = encodeHitVersion(mockVersionProps);
const KIBANA_VERSION = '2.0.0';
const CUSTOM_INDEX_TYPE = 'customIndex';
const NAMESPACE_AGNOSTIC_TYPE = 'globalType';
const MULTI_NAMESPACE_TYPE = 'shareableType';
@ -142,7 +144,7 @@ describe('SavedObjectsRepository', () => {
const documentMigrator = new DocumentMigrator({
typeRegistry: registry,
kibanaVersion: '2.0.0',
kibanaVersion: KIBANA_VERSION,
log: {},
});
@ -216,6 +218,7 @@ describe('SavedObjectsRepository', () => {
rawToSavedObject: jest.fn(),
savedObjectToRaw: jest.fn(),
generateRawId: jest.fn(),
generateRawLegacyUrlAliasId: jest.fn(),
trimIdPrefix: jest.fn(),
};
const _serializer = new SavedObjectsSerializer(registry);
@ -501,6 +504,7 @@ describe('SavedObjectsRepository', () => {
const expectSuccessResult = (obj) => ({
...obj,
migrationVersion: { [obj.type]: '1.1.1' },
coreMigrationVersion: KIBANA_VERSION,
version: mockVersion,
namespaces: obj.namespaces ?? [obj.namespace ?? 'default'],
...mockTimestampFields,
@ -954,6 +958,7 @@ describe('SavedObjectsRepository', () => {
...response.items[0].create,
_source: {
...response.items[0].create._source,
coreMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation
namespaces: response.items[0].create._source.namespaces,
},
_id: expect.stringMatching(/^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/),
@ -962,6 +967,7 @@ describe('SavedObjectsRepository', () => {
...response.items[1].create,
_source: {
...response.items[1].create._source,
coreMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation
namespaces: response.items[1].create._source.namespaces,
},
});
@ -2140,6 +2146,7 @@ describe('SavedObjectsRepository', () => {
references,
namespaces: [namespace ?? 'default'],
migrationVersion: { [type]: '1.1.1' },
coreMigrationVersion: KIBANA_VERSION,
});
});
});
@ -2724,6 +2731,7 @@ describe('SavedObjectsRepository', () => {
'type',
'references',
'migrationVersion',
'coreMigrationVersion',
'updated_at',
'originId',
'title',
@ -3254,6 +3262,231 @@ describe('SavedObjectsRepository', () => {
});
});
describe('#resolve', () => {
const type = 'index-pattern';
const id = 'logstash-*';
const aliasTargetId = 'some-other-id'; // only used for 'aliasMatch' and 'conflict' outcomes
const namespace = 'foo-namespace';
const getMockAliasDocument = (resolveCounter) => ({
body: {
get: {
_source: {
[LEGACY_URL_ALIAS_TYPE]: {
targetId: aliasTargetId,
...(resolveCounter && { resolveCounter }),
// other fields are not used by the repository
},
},
},
},
});
describe('outcomes', () => {
describe('error', () => {
const expectNotFoundError = async (type, id, options) => {
await expect(savedObjectsRepository.resolve(type, id, options)).rejects.toThrowError(
createGenericNotFoundError(type, id)
);
};
it('because type is invalid', async () => {
await expectNotFoundError('unknownType', id);
expect(client.update).not.toHaveBeenCalled();
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).not.toHaveBeenCalled();
});
it('because type is hidden', async () => {
await expectNotFoundError(HIDDEN_TYPE, id);
expect(client.update).not.toHaveBeenCalled();
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).not.toHaveBeenCalled();
});
it('because alias is not used and actual object is not found', async () => {
const options = { namespace: undefined };
const response = { found: false };
client.get.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target
);
await expectNotFoundError(type, id, options);
expect(client.update).not.toHaveBeenCalled();
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
});
it('because actual object and alias object are both not found', async () => {
const options = { namespace };
const objectResults = [
{ type, id, found: false },
{ type, id: aliasTargetId, found: false },
];
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target
);
await expectNotFoundError(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
});
});
describe('exactMatch', () => {
it('because namespace is undefined', async () => {
const options = { namespace: undefined };
const response = getMockGetResponse({ type, id });
client.get.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).not.toHaveBeenCalled();
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
});
});
describe('because alias is not used', () => {
const expectExactMatchResult = async (aliasResult) => {
const options = { namespace };
client.update.mockResolvedValueOnce(aliasResult); // for alias object
const response = getMockGetResponse({ type, id }, options.namespace);
client.get.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
});
};
it('since alias call resulted in 404', async () => {
await expectExactMatchResult({ statusCode: 404 });
});
it('since alias is not found', async () => {
await expectExactMatchResult({ body: { get: { found: false } } });
});
it('since alias is disabled', async () => {
await expectExactMatchResult({
body: { get: { _source: { [LEGACY_URL_ALIAS_TYPE]: { disabled: true } } } },
});
});
});
describe('because alias is used', () => {
const expectExactMatchResult = async (objectResults) => {
const options = { namespace };
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
});
};
it('but alias target is not found', async () => {
const objects = [
{ type, id },
{ type, id: aliasTargetId, found: false },
];
await expectExactMatchResult(objects);
});
it('but alias target does not exist in this namespace', async () => {
const objects = [
{ type: MULTI_NAMESPACE_TYPE, id }, // correct namespace field is added by getMockMgetResponse
{ type: MULTI_NAMESPACE_TYPE, id: aliasTargetId, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse
];
await expectExactMatchResult(objects);
});
});
});
describe('aliasMatch', () => {
const expectAliasMatchResult = async (objectResults) => {
const options = { namespace };
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id: aliasTargetId }),
outcome: 'aliasMatch',
});
};
it('because actual target is not found', async () => {
const objects = [
{ type, id, found: false },
{ type, id: aliasTargetId },
];
await expectAliasMatchResult(objects);
});
it('because actual target does not exist in this namespace', async () => {
const objects = [
{ type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse
{ type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse
];
await expectAliasMatchResult(objects);
});
});
describe('conflict', () => {
it('because actual target and alias target are both found', async () => {
const options = { namespace };
const objectResults = [
{ type, id }, // correct namespace field is added by getMockMgetResponse
{ type, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse
];
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'conflict',
});
});
});
});
});
describe('#incrementCounter', () => {
const type = 'config';
const id = 'one';

View file

@ -47,6 +47,7 @@ import {
SavedObjectsDeleteFromNamespacesResponse,
SavedObjectsRemoveReferencesToOptions,
SavedObjectsRemoveReferencesToResponse,
SavedObjectsResolveResponse,
} from '../saved_objects_client';
import {
SavedObject,
@ -55,6 +56,7 @@ import {
SavedObjectsMigrationVersion,
MutatingOperationRefreshSetting,
} from '../../types';
import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { validateConvertFilterToKueryNode } from './filter_utils';
import {
@ -920,25 +922,7 @@ export class SavedObjectsRepository {
} as any) as SavedObject<T>;
}
const { originId, updated_at: updatedAt } = doc._source;
let namespaces = [];
if (!this._registry.isNamespaceAgnostic(type)) {
namespaces = doc._source.namespaces ?? [
SavedObjectsUtils.namespaceIdToString(doc._source.namespace),
];
}
return {
id,
type,
namespaces,
...(originId && { originId }),
...(updatedAt && { updated_at: updatedAt }),
version: encodeHitVersion(doc),
attributes: doc._source[type],
references: doc._source.references || [],
migrationVersion: doc._source.migrationVersion,
};
return this.getSavedObjectFromSource(type, id, doc);
}),
};
}
@ -978,26 +962,122 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
const { originId, updated_at: updatedAt } = body._source;
return this.getSavedObjectFromSource(type, id, body);
}
let namespaces: string[] = [];
if (!this._registry.isNamespaceAgnostic(type)) {
namespaces = body._source.namespaces ?? [
SavedObjectsUtils.namespaceIdToString(body._source.namespace),
];
/**
* Resolves a single object, using any legacy URL alias if it exists
*
* @param {string} type
* @param {string} id
* @param {object} [options={}]
* @property {string} [options.namespace]
* @returns {promise} - { saved_object, outcome }
*/
async resolve<T = unknown>(
type: string,
id: string,
options: SavedObjectsBaseOptions = {}
): Promise<SavedObjectsResolveResponse<T>> {
if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
return {
id,
type,
namespaces,
...(originId && { originId }),
...(updatedAt && { updated_at: updatedAt }),
version: encodeHitVersion(body),
attributes: body._source[type],
references: body._source.references || [],
migrationVersion: body._source.migrationVersion,
};
const namespace = normalizeNamespace(options.namespace);
if (namespace === undefined) {
// legacy URL aliases cannot exist for the default namespace; just attempt to get the object
return this.resolveExactMatch(type, id, options);
}
const rawAliasId = this._serializer.generateRawLegacyUrlAliasId(namespace, type, id);
const time = this._getCurrentTime();
// retrieve the alias, and if it is not disabled, update it
const aliasResponse = await this.client.update(
{
id: rawAliasId,
index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE),
refresh: false,
_source: 'true',
body: {
script: {
source: `
if (ctx._source[params.type].disabled != true) {
if (ctx._source[params.type].resolveCounter == null) {
ctx._source[params.type].resolveCounter = 1;
}
else {
ctx._source[params.type].resolveCounter += 1;
}
ctx._source[params.type].lastResolved = params.time;
ctx._source.updated_at = params.time;
}
`,
lang: 'painless',
params: {
type: LEGACY_URL_ALIAS_TYPE,
time,
},
},
},
},
{ ignore: [404] }
);
if (
aliasResponse.statusCode === 404 ||
aliasResponse.body.get.found === false ||
aliasResponse.body.get._source[LEGACY_URL_ALIAS_TYPE]?.disabled === true
) {
// no legacy URL alias exists, or one exists but it's disabled; just attempt to get the object
return this.resolveExactMatch(type, id, options);
}
const legacyUrlAlias: LegacyUrlAlias = aliasResponse.body.get._source[LEGACY_URL_ALIAS_TYPE];
const objectIndex = this.getIndexForType(type);
const bulkGetResponse = await this.client.mget(
{
body: {
docs: [
{
// attempt to find an exact match for the given ID
_id: this._serializer.generateRawId(namespace, type, id),
_index: objectIndex,
},
{
// also attempt to find a match for the legacy URL alias target ID
_id: this._serializer.generateRawId(namespace, type, legacyUrlAlias.targetId),
_index: objectIndex,
},
],
},
},
{ ignore: [404] }
);
const exactMatchDoc = bulkGetResponse?.body.docs[0];
const aliasMatchDoc = bulkGetResponse?.body.docs[1];
const foundExactMatch =
exactMatchDoc.found && this.rawDocExistsInNamespace(exactMatchDoc, namespace);
const foundAliasMatch =
aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace);
if (foundExactMatch && foundAliasMatch) {
return {
saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc),
outcome: 'conflict',
};
} else if (foundExactMatch) {
return {
saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc),
outcome: 'exactMatch',
};
} else if (foundAliasMatch) {
return {
saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc),
outcome: 'aliasMatch',
};
}
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
/**
@ -1718,7 +1798,7 @@ export class SavedObjectsRepository {
if (this._registry.isSingleNamespace(type)) {
savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)];
}
return omit(savedObject, 'namespace') as SavedObject<T>;
return omit(savedObject, ['namespace']) as SavedObject<T>;
}
/**
@ -1814,6 +1894,43 @@ export class SavedObjectsRepository {
}
return body as SavedObjectsRawDoc;
}
private getSavedObjectFromSource<T>(
type: string,
id: string,
doc: { _seq_no: number; _primary_term: number; _source: SavedObjectsRawDocSource }
): SavedObject<T> {
const { originId, updated_at: updatedAt } = doc._source;
let namespaces: string[] = [];
if (!this._registry.isNamespaceAgnostic(type)) {
namespaces = doc._source.namespaces ?? [
SavedObjectsUtils.namespaceIdToString(doc._source.namespace),
];
}
return {
id,
type,
namespaces,
...(originId && { originId }),
...(updatedAt && { updated_at: updatedAt }),
version: encodeHitVersion(doc),
attributes: doc._source[type],
references: doc._source.references || [],
migrationVersion: doc._source.migrationVersion,
coreMigrationVersion: doc._source.coreMigrationVersion,
};
}
private async resolveExactMatch<T>(
type: string,
id: string,
options: SavedObjectsBaseOptions
): Promise<SavedObjectsResolveResponse<T>> {
const object = await this.get<T>(type, id, options);
return { saved_object: object, outcome: 'exactMatch' };
}
}
function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) {

View file

@ -20,6 +20,7 @@ const create = () =>
bulkGet: jest.fn(),
find: jest.fn(),
get: jest.fn(),
resolve: jest.fn(),
update: jest.fn(),
addToNamespaces: jest.fn(),
deleteFromNamespaces: jest.fn(),

View file

@ -115,6 +115,22 @@ test(`#get`, async () => {
expect(result).toBe(returnValue);
});
test(`#resolve`, async () => {
const returnValue = Symbol();
const mockRepository = {
resolve: jest.fn().mockResolvedValue(returnValue),
};
const client = new SavedObjectsClient(mockRepository);
const type = Symbol();
const id = Symbol();
const options = Symbol();
const result = await client.resolve(type, id, options);
expect(mockRepository.resolve).toHaveBeenCalledWith(type, id, options);
expect(result).toBe(returnValue);
});
test(`#update`, async () => {
const returnValue = Symbol();
const mockRepository = {

View file

@ -34,6 +34,16 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions {
version?: string;
/** {@inheritDoc SavedObjectsMigrationVersion} */
migrationVersion?: SavedObjectsMigrationVersion;
/**
* A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current
* Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the
* current Kibana version, it will result in an error.
*
* @remarks
* Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion`
* field set and you want to create it again.
*/
coreMigrationVersion?: string;
references?: SavedObjectReference[];
/** The Elasticsearch Refresh setting for this operation */
refresh?: MutatingOperationRefreshSetting;
@ -60,6 +70,16 @@ export interface SavedObjectsBulkCreateObject<T = unknown> {
references?: SavedObjectReference[];
/** {@inheritDoc SavedObjectsMigrationVersion} */
migrationVersion?: SavedObjectsMigrationVersion;
/**
* A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current
* Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the
* current Kibana version, it will result in an error.
*
* @remarks
* Do not attempt to set this manually. It should only be used if you retrieved an existing object that had the `coreMigrationVersion`
* field set and you want to create it again.
*/
coreMigrationVersion?: string;
/** Optional ID of the original saved object, if this object's `id` was regenerated */
originId?: string;
/**
@ -273,6 +293,24 @@ export interface SavedObjectsUpdateResponse<T = unknown>
references: SavedObjectReference[] | undefined;
}
/**
*
* @public
*/
export interface SavedObjectsResolveResponse<T = unknown> {
saved_object: SavedObject<T>;
/**
* The outcome for a successful `resolve` call is one of the following values:
*
* * `'exactMatch'` -- One document exactly matched the given ID.
* * `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different
* than the given ID.
* * `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the
* `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID.
*/
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
}
/**
*
* @public
@ -379,6 +417,21 @@ export class SavedObjectsClient {
return await this._repository.get(type, id, options);
}
/**
* Resolves a single object, using any legacy URL alias if it exists
*
* @param type - The type of SavedObject to retrieve
* @param id - The ID of the SavedObject to retrieve
* @param options
*/
async resolve<T = unknown>(
type: string,
id: string,
options: SavedObjectsBaseOptions = {}
): Promise<SavedObjectsResolveResponse<T>> {
return await this._repository.resolve(type, id, options);
}
/**
* Updates an SavedObject
*

View file

@ -242,6 +242,41 @@ export interface SavedObjectsType {
* An optional map of {@link SavedObjectMigrationFn | migrations} or a function returning a map of {@link SavedObjectMigrationFn | migrations} to be used to migrate the type.
*/
migrations?: SavedObjectMigrationMap | (() => SavedObjectMigrationMap);
/**
* If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.
*
* Requirements:
*
* 1. This string value must be a valid semver version
* 2. This type must have previously specified {@link SavedObjectsNamespaceType | `namespaceType: 'single'`}
* 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`}
*
* Example of a single-namespace type in 7.10:
*
* ```ts
* {
* name: 'foo',
* hidden: false,
* namespaceType: 'single',
* mappings: {...}
* }
* ```
*
* Example after converting to a multi-namespace type in 7.11:
*
* ```ts
* {
* name: 'foo',
* hidden: false,
* namespaceType: 'multiple',
* mappings: {...},
* convertToMultiNamespaceTypeVersion: '7.11.0'
* }
* ```
*
* Note: a migration function can be optionally specified for the same version.
*/
convertToMultiNamespaceTypeVersion?: string;
/**
* An optional {@link SavedObjectsTypeManagementDefinition | saved objects management section} definition for the type.
*/

View file

@ -681,6 +681,20 @@ export interface CoreUsageStats {
// (undocumented)
'apiCalls.savedObjectsImport.total'?: number;
// (undocumented)
'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsResolve.namespace.custom.total'?: number;
// (undocumented)
'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsResolve.namespace.default.total'?: number;
// (undocumented)
'apiCalls.savedObjectsResolve.total'?: number;
// (undocumented)
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.no'?: number;
// (undocumented)
'apiCalls.savedObjectsResolveImportErrors.createNewCopiesEnabled.yes'?: number;
@ -2032,6 +2046,7 @@ export type SafeRouteMethod = 'get' | 'options';
// @public (undocumented)
export interface SavedObject<T = unknown> {
attributes: T;
coreMigrationVersion?: string;
// Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@ -2116,6 +2131,7 @@ export interface SavedObjectsBaseOptions {
export interface SavedObjectsBulkCreateObject<T = unknown> {
// (undocumented)
attributes: T;
coreMigrationVersion?: string;
// (undocumented)
id?: string;
initialNamespaces?: string[];
@ -2206,6 +2222,7 @@ export class SavedObjectsClient {
find<T = unknown>(options: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>>;
get<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObject<T>>;
removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise<SavedObjectsRemoveReferencesToResponse>;
resolve<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObjectsResolveResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
}
@ -2276,6 +2293,7 @@ export interface SavedObjectsCoreFieldMapping {
// @public (undocumented)
export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions {
coreMigrationVersion?: string;
id?: string;
initialNamespaces?: string[];
migrationVersion?: SavedObjectsMigrationVersion;
@ -2722,6 +2740,11 @@ export interface SavedObjectsRawDoc {
_source: SavedObjectsRawDocSource;
}
// @public
export interface SavedObjectsRawDocParseOptions {
namespaceTreatment?: 'strict' | 'lax';
}
// @public (undocumented)
export interface SavedObjectsRemoveReferencesToOptions extends SavedObjectsBaseOptions {
refresh?: boolean;
@ -2752,6 +2775,7 @@ export class SavedObjectsRepository {
get<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObject<T>>;
incrementCounter<T = unknown>(type: string, id: string, counterFields: Array<string | SavedObjectsIncrementCounterField>, options?: SavedObjectsIncrementCounterOptions): Promise<SavedObject<T>>;
removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise<SavedObjectsRemoveReferencesToResponse>;
resolve<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObjectsResolveResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
}
@ -2769,13 +2793,21 @@ export interface SavedObjectsResolveImportErrorsOptions {
retries: SavedObjectsImportRetry[];
}
// @public (undocumented)
export interface SavedObjectsResolveResponse<T = unknown> {
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
// (undocumented)
saved_object: SavedObject<T>;
}
// @public
export class SavedObjectsSerializer {
// @internal
constructor(registry: ISavedObjectTypeRegistry);
generateRawId(namespace: string | undefined, type: string, id: string): string;
isRawSavedObject(rawDoc: SavedObjectsRawDoc): boolean;
rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc;
generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string;
isRawSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): boolean;
rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc;
savedObjectToRaw(savedObj: SavedObjectSanitizedDoc): SavedObjectsRawDoc;
}
@ -2810,6 +2842,7 @@ export interface SavedObjectStatusMeta {
// @public (undocumented)
export interface SavedObjectsType {
convertToAliasScript?: string;
convertToMultiNamespaceTypeVersion?: string;
hidden: boolean;
indexPattern?: string;
management?: SavedObjectsTypeManagementDefinition;

View file

@ -82,6 +82,8 @@ export interface SavedObject<T = unknown> {
references: SavedObjectReference[];
/** {@inheritdoc SavedObjectsMigrationVersion} */
migrationVersion?: SavedObjectsMigrationVersion;
/** A semver value that is used when upgrading objects between Kibana versions. */
coreMigrationVersion?: string;
/** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */
namespaces?: string[];
/**

View file

@ -1142,7 +1142,7 @@ export class Plugin implements Plugin_2<PluginSetup, PluginStart, DataPluginSetu
fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise<import("../common").FieldFormatsRegistry>;
};
indexPatterns: {
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../core/server").SavedObjectsClient, "update" | "find" | "get" | "delete" | "errors" | "create" | "bulkCreate" | "checkConflicts" | "bulkGet" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo">, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise<import("../public").IndexPatternsService>;
indexPatternsServiceFactory: (savedObjectsClient: Pick<import("../../../core/server").SavedObjectsClient, "update" | "find" | "get" | "resolve" | "delete" | "errors" | "create" | "bulkCreate" | "checkConflicts" | "bulkGet" | "addToNamespaces" | "deleteFromNamespaces" | "bulkUpdate" | "removeReferencesTo">, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise<import("../public").IndexPatternsService>;
};
search: ISearchStart<import("./search").IEsSearchRequest, import("./search").IEsSearchResponse<any>>;
};

View file

@ -154,6 +154,13 @@ export function getCoreUsageCollector(
'apiCalls.savedObjectsGet.namespace.custom.total': { type: 'long' },
'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.yes': { type: 'long' },
'apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.no': { type: 'long' },
'apiCalls.savedObjectsResolve.total': { type: 'long' },
'apiCalls.savedObjectsResolve.namespace.default.total': { type: 'long' },
'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes': { type: 'long' },
'apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no': { type: 'long' },
'apiCalls.savedObjectsResolve.namespace.custom.total': { type: 'long' },
'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes': { type: 'long' },
'apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no': { type: 'long' },
'apiCalls.savedObjectsUpdate.total': { type: 'long' },
'apiCalls.savedObjectsUpdate.namespace.default.total': { type: 'long' },
'apiCalls.savedObjectsUpdate.namespace.default.kibanaRequest.yes': { type: 'long' },

View file

@ -3864,6 +3864,27 @@
"apiCalls.savedObjectsGet.namespace.custom.kibanaRequest.no": {
"type": "long"
},
"apiCalls.savedObjectsResolve.total": {
"type": "long"
},
"apiCalls.savedObjectsResolve.namespace.default.total": {
"type": "long"
},
"apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.yes": {
"type": "long"
},
"apiCalls.savedObjectsResolve.namespace.default.kibanaRequest.no": {
"type": "long"
},
"apiCalls.savedObjectsResolve.namespace.custom.total": {
"type": "long"
},
"apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.yes": {
"type": "long"
},
"apiCalls.savedObjectsResolve.namespace.custom.kibanaRequest.no": {
"type": "long"
},
"apiCalls.savedObjectsUpdate.total": {
"type": "long"
},

View file

@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { getKibanaVersion } from './lib/saved_objects_test_utils';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -21,6 +22,7 @@ export default function ({ getService }: FtrProviderContext) {
attributes: {
title: 'An existing visualization',
},
coreMigrationVersion: '1.2.3',
},
{
type: 'dashboard',
@ -32,6 +34,12 @@ export default function ({ getService }: FtrProviderContext) {
];
describe('_bulk_create', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await getKibanaVersion(getService);
});
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
@ -65,6 +73,7 @@ export default function ({ getService }: FtrProviderContext) {
migrationVersion: {
dashboard: resp.body.saved_objects[1].migrationVersion.dashboard,
},
coreMigrationVersion: KIBANA_VERSION,
references: [],
namespaces: ['default'],
},
@ -112,6 +121,7 @@ export default function ({ getService }: FtrProviderContext) {
migrationVersion: {
visualization: resp.body.saved_objects[0].migrationVersion.visualization,
},
coreMigrationVersion: KIBANA_VERSION, // updated from 1.2.3 to the latest kibana version
},
{
type: 'dashboard',
@ -126,6 +136,7 @@ export default function ({ getService }: FtrProviderContext) {
migrationVersion: {
dashboard: resp.body.saved_objects[1].migrationVersion.dashboard,
},
coreMigrationVersion: KIBANA_VERSION,
},
],
});

View file

@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { getKibanaVersion } from './lib/saved_objects_test_utils';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -30,6 +31,12 @@ export default function ({ getService }: FtrProviderContext) {
];
describe('_bulk_get', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await getKibanaVersion(getService);
});
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
@ -58,6 +65,7 @@ export default function ({ getService }: FtrProviderContext) {
resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta,
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
namespaces: ['default'],
references: [
{
@ -87,6 +95,7 @@ export default function ({ getService }: FtrProviderContext) {
},
namespaces: ['default'],
migrationVersion: resp.body.saved_objects[2].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
references: [],
},
],

View file

@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { getKibanaVersion } from './lib/saved_objects_test_utils';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -15,6 +16,12 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
describe('create', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await getKibanaVersion(getService);
});
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
@ -42,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) {
id: resp.body.id,
type: 'visualization',
migrationVersion: resp.body.migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
updated_at: resp.body.updated_at,
version: resp.body.version,
attributes: {
@ -53,6 +61,21 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.body.migrationVersion).to.be.ok();
});
});
it('result should be updated to the latest coreMigrationVersion', async () => {
await supertest
.post(`/api/saved_objects/visualization`)
.send({
attributes: {
title: 'My favorite vis',
},
coreMigrationVersion: '1.2.3',
})
.expect(200)
.then((resp) => {
expect(resp.body.coreMigrationVersion).to.eql(KIBANA_VERSION);
});
});
});
describe('without kibana index', () => {
@ -86,6 +109,7 @@ export default function ({ getService }: FtrProviderContext) {
id: resp.body.id,
type: 'visualization',
migrationVersion: resp.body.migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
updated_at: resp.body.updated_at,
version: resp.body.version,
attributes: {
@ -99,6 +123,21 @@ export default function ({ getService }: FtrProviderContext) {
expect((await es.indices.exists({ index: '.kibana' })).body).to.be(true);
});
it('result should have the latest coreMigrationVersion', async () => {
await supertest
.post(`/api/saved_objects/visualization`)
.send({
attributes: {
title: 'My favorite vis',
},
coreMigrationVersion: '1.2.3',
})
.expect(200)
.then((resp) => {
expect(resp.body.coreMigrationVersion).to.eql(KIBANA_VERSION);
});
});
});
});
}

View file

@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../ftr_provider_context';
import { getKibanaVersion } from './lib/saved_objects_test_utils';
function ndjsonToObject(input: string) {
return input.split('\n').map((str) => JSON.parse(str));
@ -18,6 +19,12 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
describe('export', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await getKibanaVersion(getService);
});
describe('with kibana index', () => {
describe('basic amount of saved objects', () => {
before(() => esArchiver.load('saved_objects/basic'));
@ -312,6 +319,7 @@ export default function ({ getService }: FtrProviderContext) {
},
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
migrationVersion: objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
@ -371,6 +379,7 @@ export default function ({ getService }: FtrProviderContext) {
},
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
migrationVersion: objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
@ -435,6 +444,7 @@ export default function ({ getService }: FtrProviderContext) {
},
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
migrationVersion: objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',

View file

@ -9,6 +9,7 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { SavedObject } from '../../../../src/core/server';
import { getKibanaVersion } from './lib/saved_objects_test_utils';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -16,6 +17,12 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
describe('find', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await getKibanaVersion(getService);
});
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
@ -39,6 +46,7 @@ export default function ({ getService }: FtrProviderContext) {
},
score: 0,
migrationVersion: resp.body.saved_objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
namespaces: ['default'],
references: [
{
@ -134,6 +142,7 @@ export default function ({ getService }: FtrProviderContext) {
title: 'Count of requests',
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
namespaces: ['default'],
score: 0,
references: [
@ -170,6 +179,7 @@ export default function ({ getService }: FtrProviderContext) {
title: 'Count of requests',
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
namespaces: ['default'],
score: 0,
references: [
@ -187,6 +197,7 @@ export default function ({ getService }: FtrProviderContext) {
},
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
migrationVersion: resp.body.saved_objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
namespaces: ['foo-ns'],
references: [
{
@ -202,7 +213,6 @@ export default function ({ getService }: FtrProviderContext) {
},
],
});
expect(resp.body.saved_objects[0].migrationVersion).to.be.ok();
}));
});
@ -244,6 +254,7 @@ export default function ({ getService }: FtrProviderContext) {
},
],
migrationVersion: resp.body.saved_objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
updated_at: '2017-09-21T18:51:23.794Z',
version: 'WzIsMV0=',
},

View file

@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { getKibanaVersion } from './lib/saved_objects_test_utils';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -15,6 +16,12 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
describe('get', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await getKibanaVersion(getService);
});
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
@ -30,6 +37,7 @@ export default function ({ getService }: FtrProviderContext) {
updated_at: '2017-09-21T18:51:23.794Z',
version: resp.body.version,
migrationVersion: resp.body.migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
attributes: {
title: 'Count of requests',
description: '',

View file

@ -12,15 +12,16 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('saved_objects', () => {
loadTestFile(require.resolve('./bulk_create'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./export'));
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./import'));
loadTestFile(require.resolve('./migrations'));
loadTestFile(require.resolve('./resolve'));
loadTestFile(require.resolve('./resolve_import_errors'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./migrations'));
});
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export async function getKibanaVersion(getService: FtrProviderContext['getService']) {
const kibanaServer = getService('kibanaServer');
const kibanaVersion = await kibanaServer.version.get();
expect(typeof kibanaVersion).to.eql('string');
expect(kibanaVersion.length).to.be.greaterThan(0);
return kibanaVersion;
}

View file

@ -10,10 +10,11 @@
* Smokescreen tests for core migration logic
*/
import uuidv5 from 'uuid/v5';
import { set } from '@elastic/safer-lodash-set';
import _ from 'lodash';
import expect from '@kbn/expect';
import { ElasticsearchClient, SavedObjectMigrationMap, SavedObjectsType } from 'src/core/server';
import { ElasticsearchClient, SavedObjectsType } from 'src/core/server';
import { SearchResponse } from '../../../../src/core/server/elasticsearch/client';
import {
DocumentMigrator,
@ -28,6 +29,26 @@ import {
} from '../../../../src/core/server/saved_objects';
import { FtrProviderContext } from '../../ftr_provider_context';
const KIBANA_VERSION = '99.9.9';
const FOO_TYPE: SavedObjectsType = {
name: 'foo',
hidden: false,
namespaceType: 'single',
mappings: { properties: {} },
};
const BAR_TYPE: SavedObjectsType = {
name: 'bar',
hidden: false,
namespaceType: 'single',
mappings: { properties: {} },
};
const BAZ_TYPE: SavedObjectsType = {
name: 'baz',
hidden: false,
namespaceType: 'single',
mappings: { properties: {} },
};
function getLogMock() {
return {
debug() {},
@ -61,16 +82,22 @@ export default ({ getService }: FtrProviderContext) => {
bar: { properties: { mynum: { type: 'integer' } } },
};
const migrations: Record<string, SavedObjectMigrationMap> = {
foo: {
'1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()),
const savedObjectTypes: SavedObjectsType[] = [
{
...FOO_TYPE,
migrations: {
'1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()),
},
},
bar: {
'1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1),
'1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }),
'1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2),
{
...BAR_TYPE,
migrations: {
'1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1),
'1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }),
'1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2),
},
},
};
];
await createIndex({ esClient, index });
await createDocs({ esClient, index, docs: originalDocs });
@ -107,7 +134,7 @@ export default ({ getService }: FtrProviderContext) => {
const result = await migrateIndex({
esClient,
index,
migrations,
savedObjectTypes,
mappingProperties,
obsoleteIndexTemplatePattern: 'migration_a*',
});
@ -129,13 +156,7 @@ export default ({ getService }: FtrProviderContext) => {
});
// The docs in the original index are unchanged
expect(await fetchDocs(esClient, `${index}_1`)).to.eql([
{ id: 'bar:i', type: 'bar', bar: { nomnom: 33 } },
{ id: 'bar:o', type: 'bar', bar: { nomnom: 2 } },
{ id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' } },
{ id: 'foo:a', type: 'foo', foo: { name: 'Foo A' } },
{ id: 'foo:e', type: 'foo', foo: { name: 'Fooey' } },
]);
expect(await fetchDocs(esClient, `${index}_1`)).to.eql(originalDocs.sort(sortByTypeAndId));
// The docs in the alias have been migrated
expect(await fetchDocs(esClient, index)).to.eql([
@ -145,6 +166,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { bar: '1.9.0' },
bar: { mynum: 68 },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'bar:o',
@ -152,14 +174,22 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { bar: '1.9.0' },
bar: { mynum: 6 },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'baz:u',
type: 'baz',
baz: { title: 'Terrific!' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{ id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' }, references: [] },
{
id: 'foo:a',
type: 'foo',
migrationVersion: { foo: '1.0.0' },
foo: { name: 'FOO A' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'foo:e',
@ -167,6 +197,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { foo: '1.0.0' },
foo: { name: 'FOOEY' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
]);
});
@ -185,28 +216,46 @@ export default ({ getService }: FtrProviderContext) => {
bar: { properties: { mynum: { type: 'integer' } } },
};
const migrations: Record<string, SavedObjectMigrationMap> = {
foo: {
'1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()),
let savedObjectTypes: SavedObjectsType[] = [
{
...FOO_TYPE,
migrations: {
'1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()),
},
},
bar: {
'1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1),
'1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }),
'1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2),
{
...BAR_TYPE,
migrations: {
'1.0.0': (doc) => set(doc, 'attributes.nomnom', doc.attributes.nomnom + 1),
'1.3.0': (doc) => set(doc, 'attributes', { mynum: doc.attributes.nomnom }),
'1.9.0': (doc) => set(doc, 'attributes.mynum', doc.attributes.mynum * 2),
},
},
};
];
await createIndex({ esClient, index });
await createDocs({ esClient, index, docs: originalDocs });
await migrateIndex({ esClient, index, migrations, mappingProperties });
await migrateIndex({ esClient, index, savedObjectTypes, mappingProperties });
// @ts-expect-error name doesn't exist on mynum type
mappingProperties.bar.properties.name = { type: 'keyword' };
migrations.foo['2.0.1'] = (doc) => set(doc, 'attributes.name', `${doc.attributes.name}v2`);
migrations.bar['2.3.4'] = (doc) => set(doc, 'attributes.name', `NAME ${doc.id}`);
savedObjectTypes = [
{
...FOO_TYPE,
migrations: {
'2.0.1': (doc) => set(doc, 'attributes.name', `${doc.attributes.name}v2`),
},
},
{
...BAR_TYPE,
migrations: {
'2.3.4': (doc) => set(doc, 'attributes.name', `NAME ${doc.id}`),
},
},
];
await migrateIndex({ esClient, index, migrations, mappingProperties });
await migrateIndex({ esClient, index, savedObjectTypes, mappingProperties });
// The index for the initial migration has not been destroyed...
expect(await fetchDocs(esClient, `${index}_2`)).to.eql([
@ -216,6 +265,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { bar: '1.9.0' },
bar: { mynum: 68 },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'bar:o',
@ -223,6 +273,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { bar: '1.9.0' },
bar: { mynum: 6 },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'foo:a',
@ -230,6 +281,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { foo: '1.0.0' },
foo: { name: 'FOO A' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'foo:e',
@ -237,6 +289,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { foo: '1.0.0' },
foo: { name: 'FOOEY' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
]);
@ -248,6 +301,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { bar: '2.3.4' },
bar: { mynum: 68, name: 'NAME i' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'bar:o',
@ -255,6 +309,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { bar: '2.3.4' },
bar: { mynum: 6, name: 'NAME o' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'foo:a',
@ -262,6 +317,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { foo: '2.0.1' },
foo: { name: 'FOO Av2' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'foo:e',
@ -269,6 +325,7 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { foo: '2.0.1' },
foo: { name: 'FOOEYv2' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
]);
});
@ -281,18 +338,21 @@ export default ({ getService }: FtrProviderContext) => {
foo: { properties: { name: { type: 'text' } } },
};
const migrations: Record<string, SavedObjectMigrationMap> = {
foo: {
'1.0.0': (doc) => set(doc, 'attributes.name', 'LOTR'),
const savedObjectTypes: SavedObjectsType[] = [
{
...FOO_TYPE,
migrations: {
'1.0.0': (doc) => set(doc, 'attributes.name', 'LOTR'),
},
},
};
];
await createIndex({ esClient, index });
await createDocs({ esClient, index, docs: originalDocs });
const result = await Promise.all([
migrateIndex({ esClient, index, migrations, mappingProperties }),
migrateIndex({ esClient, index, migrations, mappingProperties }),
migrateIndex({ esClient, index, savedObjectTypes, mappingProperties }),
migrateIndex({ esClient, index, savedObjectTypes, mappingProperties }),
]);
// The polling instance and the migrating instance should both
@ -327,9 +387,170 @@ export default ({ getService }: FtrProviderContext) => {
migrationVersion: { foo: '1.0.0' },
foo: { name: 'LOTR' },
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
]);
});
it('Correctly applies reference transforms and conversion transforms', async () => {
const index = '.migration-d';
const originalDocs = [
{ id: 'foo:1', type: 'foo', foo: { name: 'Foo 1 default' } },
{ id: 'spacex:foo:1', type: 'foo', foo: { name: 'Foo 1 spacex' }, namespace: 'spacex' },
{
id: 'bar:1',
type: 'bar',
bar: { nomnom: 1 },
references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }],
},
{
id: 'spacex:bar:1',
type: 'bar',
bar: { nomnom: 2 },
references: [{ type: 'foo', id: '1', name: 'Foo 1 spacex' }],
namespace: 'spacex',
},
{
id: 'baz:1',
type: 'baz',
baz: { title: 'Baz 1 default' },
references: [{ type: 'bar', id: '1', name: 'Bar 1 default' }],
},
{
id: 'spacex:baz:1',
type: 'baz',
baz: { title: 'Baz 1 spacex' },
references: [{ type: 'bar', id: '1', name: 'Bar 1 spacex' }],
namespace: 'spacex',
},
];
const mappingProperties = {
foo: { properties: { name: { type: 'text' } } },
bar: { properties: { nomnom: { type: 'integer' } } },
baz: { properties: { title: { type: 'keyword' } } },
};
const savedObjectTypes: SavedObjectsType[] = [
{
...FOO_TYPE,
namespaceType: 'multiple',
convertToMultiNamespaceTypeVersion: '1.0.0',
},
{
...BAR_TYPE,
namespaceType: 'multiple',
convertToMultiNamespaceTypeVersion: '2.0.0',
},
BAZ_TYPE, // must be registered for reference transforms to be applied to objects of this type
];
await createIndex({ esClient, index });
await createDocs({ esClient, index, docs: originalDocs });
await migrateIndex({
esClient,
index,
savedObjectTypes,
mappingProperties,
obsoleteIndexTemplatePattern: 'migration_a*',
});
// The docs in the original index are unchanged
expect(await fetchDocs(esClient, `${index}_1`)).to.eql(originalDocs.sort(sortByTypeAndId));
// The docs in the alias have been migrated
const migratedDocs = await fetchDocs(esClient, index);
// each newly converted multi-namespace object in a non-default space has its ID deterministically regenerated, and a legacy-url-alias
// object is created which links the old ID to the new ID
const newFooId = uuidv5('spacex:foo:1', uuidv5.DNS);
const newBarId = uuidv5('spacex:bar:1', uuidv5.DNS);
expect(migratedDocs).to.eql(
[
{
id: 'foo:1',
type: 'foo',
foo: { name: 'Foo 1 default' },
references: [],
namespaces: ['default'],
migrationVersion: { foo: '1.0.0' },
coreMigrationVersion: KIBANA_VERSION,
},
{
id: `foo:${newFooId}`,
type: 'foo',
foo: { name: 'Foo 1 spacex' },
references: [],
namespaces: ['spacex'],
originId: '1',
migrationVersion: { foo: '1.0.0' },
coreMigrationVersion: KIBANA_VERSION,
},
{
// new object
id: 'legacy-url-alias:spacex:foo:1',
type: 'legacy-url-alias',
'legacy-url-alias': {
targetId: newFooId,
targetNamespace: 'spacex',
targetType: 'foo',
},
migrationVersion: {},
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'bar:1',
type: 'bar',
bar: { nomnom: 1 },
references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }],
namespaces: ['default'],
migrationVersion: { bar: '2.0.0' },
coreMigrationVersion: KIBANA_VERSION,
},
{
id: `bar:${newBarId}`,
type: 'bar',
bar: { nomnom: 2 },
references: [{ type: 'foo', id: newFooId, name: 'Foo 1 spacex' }],
namespaces: ['spacex'],
originId: '1',
migrationVersion: { bar: '2.0.0' },
coreMigrationVersion: KIBANA_VERSION,
},
{
// new object
id: 'legacy-url-alias:spacex:bar:1',
type: 'legacy-url-alias',
'legacy-url-alias': {
targetId: newBarId,
targetNamespace: 'spacex',
targetType: 'bar',
},
migrationVersion: {},
references: [],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'baz:1',
type: 'baz',
baz: { title: 'Baz 1 default' },
references: [{ type: 'bar', id: '1', name: 'Bar 1 default' }],
coreMigrationVersion: KIBANA_VERSION,
},
{
id: 'spacex:baz:1',
type: 'baz',
baz: { title: 'Baz 1 spacex' },
references: [{ type: 'bar', id: newBarId, name: 'Bar 1 spacex' }],
namespace: 'spacex',
coreMigrationVersion: KIBANA_VERSION,
},
].sort(sortByTypeAndId)
);
});
});
};
@ -340,6 +561,30 @@ async function createIndex({ esClient, index }: { esClient: ElasticsearchClient;
foo: { properties: { name: { type: 'keyword' } } },
bar: { properties: { nomnom: { type: 'integer' } } },
baz: { properties: { title: { type: 'keyword' } } },
'legacy-url-alias': {
properties: {
targetNamespace: { type: 'text' },
targetType: { type: 'text' },
targetId: { type: 'text' },
lastResolved: { type: 'date' },
resolveCounter: { type: 'integer' },
disabled: { type: 'boolean' },
},
},
namespace: { type: 'keyword' },
namespaces: { type: 'keyword' },
originId: { type: 'keyword' },
references: {
type: 'nested',
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
coreMigrationVersion: {
type: 'keyword',
},
};
await esClient.indices.create({
index,
@ -369,23 +614,23 @@ async function createDocs({
async function migrateIndex({
esClient,
index,
migrations,
savedObjectTypes,
mappingProperties,
obsoleteIndexTemplatePattern,
}: {
esClient: ElasticsearchClient;
index: string;
migrations: Record<string, SavedObjectMigrationMap>;
savedObjectTypes: SavedObjectsType[];
mappingProperties: SavedObjectsTypeMappingDefinitions;
obsoleteIndexTemplatePattern?: string;
}) {
const typeRegistry = new SavedObjectTypeRegistry();
const types = migrationsToTypes(migrations);
types.forEach((type) => typeRegistry.registerType(type));
savedObjectTypes.forEach((type) => typeRegistry.registerType(type));
const documentMigrator = new DocumentMigrator({
kibanaVersion: '99.9.9',
kibanaVersion: KIBANA_VERSION,
typeRegistry,
minimumConvertVersion: '0.0.0', // bypass the restriction of a minimum version of 8.0.0 for these integration tests
log: getLogMock(),
});
@ -395,6 +640,7 @@ async function migrateIndex({
client: createMigrationEsClient(esClient, getLogMock()),
documentMigrator,
index,
kibanaVersion: KIBANA_VERSION,
obsoleteIndexTemplatePattern,
mappingProperties,
batchSize: 10,
@ -407,18 +653,6 @@ async function migrateIndex({
return await migrator.migrate();
}
function migrationsToTypes(
migrations: Record<string, SavedObjectMigrationMap>
): SavedObjectsType[] {
return Object.entries(migrations).map(([type, migrationsMap]) => ({
name: type,
hidden: false,
namespaceType: 'single',
mappings: { properties: {} },
migrations: { ...migrationsMap },
}));
}
async function fetchDocs(esClient: ElasticsearchClient, index: string) {
const { body } = await esClient.search<SearchResponse<any>>({ index });
@ -427,5 +661,9 @@ async function fetchDocs(esClient: ElasticsearchClient, index: string) {
...h._source,
id: h._id,
}))
.sort((a, b) => a.id.localeCompare(b.id));
.sort(sortByTypeAndId);
}
function sortByTypeAndId(a: { type: string; id: string }, b: { type: string; id: string }) {
return a.type.localeCompare(b.type) || a.id.localeCompare(b.id);
}

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../ftr_provider_context';
import { getKibanaVersion } from './lib/saved_objects_test_utils';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const es = getService('legacyEs');
const esArchiver = getService('esArchiver');
describe('resolve', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await getKibanaVersion(getService);
});
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200', async () =>
await supertest
.get(`/api/saved_objects/resolve/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`)
.expect(200)
.then((resp) => {
expect(resp.body).to.eql({
saved_object: {
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
type: 'visualization',
updated_at: '2017-09-21T18:51:23.794Z',
version: resp.body.saved_object.version,
migrationVersion: resp.body.saved_object.migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
attributes: {
title: 'Count of requests',
description: '',
version: 1,
// cheat for some of the more complex attributes
visState: resp.body.saved_object.attributes.visState,
uiStateJSON: resp.body.saved_object.attributes.uiStateJSON,
kibanaSavedObjectMeta: resp.body.saved_object.attributes.kibanaSavedObjectMeta,
},
references: [
{
type: 'index-pattern',
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
},
],
namespaces: ['default'],
},
outcome: 'exactMatch',
});
expect(resp.body.saved_object.migrationVersion).to.be.ok();
}));
describe('doc does not exist', () => {
it('should return same generic error as when index does not exist', async () =>
await supertest
.get(`/api/saved_objects/resolve/visualization/foobar`)
.expect(404)
.then((resp) => {
expect(resp.body).to.eql({
error: 'Not Found',
message: 'Saved object [visualization/foobar] not found',
statusCode: 404,
});
}));
});
});
describe('without kibana index', () => {
before(
async () =>
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
);
it('should return basic 404 without mentioning index', async () =>
await supertest
.get('/api/saved_objects/resolve/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab')
.expect(404)
.then((resp) => {
expect(resp.body).to.eql({
error: 'Not Found',
message:
'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found',
statusCode: 404,
});
}));
});
});
}

View file

@ -14,8 +14,17 @@ export default function ({ getService }: FtrProviderContext) {
const es = getService('es');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
describe('find', () => {
let KIBANA_VERSION: string;
before(async () => {
KIBANA_VERSION = await kibanaServer.version.get();
expect(typeof KIBANA_VERSION).to.eql('string');
expect(KIBANA_VERSION.length).to.be.greaterThan(0);
});
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
@ -38,6 +47,7 @@ export default function ({ getService }: FtrProviderContext) {
title: 'Count of requests',
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
coreMigrationVersion: KIBANA_VERSION,
namespaces: ['default'],
references: [
{

Some files were not shown because too many files have changed in this diff Show more