Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/extend/saved-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,44 @@ const myType: SavedObjectsType = {
};
```

### Transitioning legacy Saved Objects

If you are updating a legacy Saved Object (SO) type that lacks a model version, you must first establish a baseline. This requires a two-step PR process to ensure that Serverless environments can be safely rolled back in an emergency.

#### The Initial Version PR

The first PR must define the **current, existing shape** of the Saved Object.

- No Mapping Changes: The initial version must not alter any existing mappings; it should only introduce the required schemas.
- Deployment Requirement: This PR must be merged and released in Serverless before you submit a second PR with your desired changes.

#### Schema definition

While you can use a minimal configuration for the initial version, we recommend defining `create` and `forwardCompatibility` schemas that closely reflect your SO’s current structure. This enables full **Saved Objects Repository (SOR)** validation for both creation and retrieval.

#### Minimal configuration example

```ts
const myType: SavedObjectsType = {
...
modelVersions: {
1: {
changes: [],
schemas: {
create: schema.object({}, { unknowns: 'allow' }),
forwardCompatibility: (attrs) => _.pick([
'knownField1',
'knownField2',
...
'knownFieldN',
]),
},
},
...
```

If your Saved Object type was defining `schemas:` along with the legacy `migrations:`, you can simply use the latest schema for the initial version.

## Structure of a model version [_structure_of_a_model_version]

[Model versions](https://github.com/elastic/kibana/blob/master/src/core/packages/saved-objects/server/src/model_version/model_version.ts#L12-L20) are not just functions as the previous migrations were, but structured objects describing how the version behaves and what changed since the last one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ export function runCheckSavedObjectsCli() {
{ fallbackRenderer: 'simple', exitOnError: false }
).run(context);
}
if (exitCode) {
log.warning(
'Validation Failed. Please refer to our troubleshooting guide for more information: https://www.elastic.co/docs/extend/kibana/saved-objects#troubleshooting'
);
}
process.exit(exitCode);
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function checkDocuments({
const messages = [
`❌ A document of type '${type}' did NOT match any of the fixtures`,
...documents.map((fixture, index) => [
`document 🆚 fixtures['${version}'][${index}] (${relativePath})`,
`document 🆚 fixtures['${version}'][${index}] (${relativePath})\n`,
diff(fixture, attributes),
]),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ export const createBaseline: Task = async (ctx, task) => {
const subtasks: ListrTask<TaskContext>[] = [
{
title: `Delete pre-existing '${defaultKibanaIndex}' index`,
task: async () =>
task: async () => {
// TODO the delete operation does not seem to delete the system index
// this causes issues when running multiple times with the --server and --client flags
await client.indices.delete({
index: defaultKibanaIndex,
ignore_unavailable: true,
}),
});
},
},
{
title: `Create '${defaultKibanaIndex}' index with previous version mappings`,
Expand All @@ -46,9 +49,9 @@ export const createBaseline: Task = async (ctx, task) => {
// convert the fixtures into SavedObjectsBulkCreateObject[]
const allDocs = Object.entries(ctx.fixtures.previous).flatMap(
([type, { version, documents }]) => {
// This is a special case for when a type has no migrations yet, we do not send the typeMigrationVersion field
// because this type has not been migrated under the model version system yet.
if (version === '10.0.0') {
// When a type has no migrations nor modelVersions
// we should not send the typeMigrationVersion field
if (version === '0.0.0') {
version = undefined as unknown as string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ export const testRollback: Task = async (ctx, task) => {

const subtasks: ListrTask<TaskContext>[] = [
{
title: `Run rollback migration on updated types: '${ctx.updatedTypes
title: `Run rollback migration on updated types: '${updatedTypes
.map(({ name }) => name)
.join(', ')}'`,
task: async () => await performRollback(),
},
{
title: `Ensure SO API-retrieved SOs match previous version fixtures`,
title: `Ensure API-retrieved SOs match previous version fixtures`,
task: checkDocuments({
repository: savedObjectsRepository,
types: previousVersionTypes,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@
{
"old-type-no-migrations": {
"dynamic": false,
"properties": {
"aTextAttribute": {
"type": "text"
},
"aBooleanAttribute": {
"type": "boolean"
}
}
},
"old-type-with-migrations": {
"dynamic": false,
"properties": {
"aTextAttribute": {
"type": "text"
},
"textLength": {
"type": "integer"
}
}
},
"person-so-type": {
"dynamic": false,
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@
"pullRequestUrl": null
},
"typeDefinitions": {
"old-type-no-migrations": {
"name": "old-type-no-migrations",
"migrationVersions": [],
"hash": "4631898d09a686928fabbbe1ad0b7f2b22c44b565443408ab5d20b4dcae64e35",
"modelVersions": [],
"schemaVersions": [],
"mappings": {
"dynamic": false,
"properties.aTextAttribute.type": "text",
"properties.aBooleanAttribute.type": "boolean"
}
},
"old-type-with-migrations": {
"name": "old-type-with-migrations",
"migrationVersions": [
"8.7.0"
],
"hash": "a7916755843d9ed712ccefb6888ebcbdd2f2f3628e058bfd174454aef644437a",
"modelVersions": [],
"schemaVersions": [
"8.7.0"
],
"mappings": {
"dynamic": false,
"properties.aTextAttribute.type": "text",
"properties.aBooleanAttribute.type": "boolean"
}
},
"person-so-type": {
"name": "person-so-type",
"migrationVersions": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@

import { resolve } from 'path';

export { TEST_TYPES } from './test_types';
export { TEST_TYPES } from './types';
export { getTestSnapshots } from './snapshots';
export const BASELINE_MAPPINGS_TEST = resolve(__dirname, './baseline_mappings.json');
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { SavedObjectsType } from '@kbn/core-saved-objects-server';
import { defaultKibanaIndex } from '@kbn/migrator-test-kit';
import { OLD_TYPE_NO_MIGRATIONS } from './no_migrations';
import { OLD_TYPE_WITH_MIGRATIONS } from './migrations';
import { PERSON_SO_TYPE } from './person';

function createSavedObjectType<T>(properties: Partial<SavedObjectsType<T>>): SavedObjectsType<T> {
return {
name: 'unnamed',
indexPattern: defaultKibanaIndex,
hidden: false,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {
foo: { type: 'keyword' },
bar: { type: 'boolean' },
},
},
...properties,
};
}

export const TEST_TYPES: SavedObjectsType<any>[] = [
OLD_TYPE_NO_MIGRATIONS,
OLD_TYPE_WITH_MIGRATIONS,
PERSON_SO_TYPE,
].map((partial) => createSavedObjectType(partial));
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { schema } from '@kbn/config-schema';
import type { SavedObjectsType } from '@kbn/core-saved-objects-server';

const initialSchema = schema.object({
aTextAttribute: schema.string(),
textLength: schema.number(),
anIntegerAttribute: schema.number(),
});

export const OLD_TYPE_WITH_MIGRATIONS: Partial<SavedObjectsType> = {
name: 'old-type-with-migrations',
mappings: {
dynamic: false,
properties: {
aTextAttribute: { type: 'text' },
aBooleanAttribute: { type: 'boolean' },
anIntegerAttribute: { type: 'integer' },
},
},
migrations: {
'8.7.0': (doc) => ({
...doc,
attributes: { ...doc.attributes, textLength: doc.attributes.aTextAttribute.length },
}),
},
modelVersions: {
1: {
changes: [],
schemas: {
create: initialSchema,
forwardCompatibility: initialSchema.extends({}, { unknowns: 'ignore' }),
},
},
},
};
Original file line number Diff line number Diff line change
@@ -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
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { schema } from '@kbn/config-schema';
import type { SavedObjectsType } from '@kbn/core-saved-objects-server';

const v1 = schema.object({
aTextAttribute: schema.string(),
aBooleanAttribute: schema.boolean(),
anIntegerAttribute: schema.number(),
});

export const OLD_TYPE_NO_MIGRATIONS: Partial<SavedObjectsType> = {
name: 'old-type-no-migrations',
mappings: {
dynamic: false,
properties: {
aTextAttribute: { type: 'text' },
aBooleanAttribute: { type: 'boolean' },
anIntegerAttribute: { type: 'integer' },
},
},
modelVersions: {
1: {
changes: [],
schemas: {
create: v1,
forwardCompatibility: v1.extends({}, { unknowns: 'ignore' }),
},
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const fullNameBackfill: SavedObjectModelDataBackfillFn<PersonV1, PersonV2> = (do
};
};

const PERSON_SO_TYPE = createSavedObjectType({
export const PERSON_SO_TYPE: Partial<SavedObjectsType> = {
name: 'person-so-type',
mappings: {
dynamic: false,
Expand Down Expand Up @@ -79,22 +79,4 @@ const PERSON_SO_TYPE = createSavedObjectType({
},
},
},
});

function createSavedObjectType<T>(properties: Partial<SavedObjectsType<T>>): SavedObjectsType<T> {
return {
name: 'unnamed',
hidden: false,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {
foo: { type: 'keyword' },
bar: { type: 'boolean' },
},
},
...properties,
};
}

export const TEST_TYPES: SavedObjectsType<any>[] = [PERSON_SO_TYPE];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"0.0.0": [
{
"aTextAttribute": "Hello world!",
"aBooleanAttribute": "false"
}
],
"10.1.0": [
{
"aTextAttribute": "Hello world!",
"aBooleanAttribute": "false"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"8.7.0": [
{
"aTextAttribute": "Hello world!",
"textLength": 12
}
],
"10.1.0": [
{
"aTextAttribute": "Hello world!",
"textLength": 12
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ export async function createFixtureFile({
await jsonToFile(path, {
[previous]: [
{
TODO: `Please create one or more test objects with properties that reflect real '${type}' objects`,
TODO: `Please create one or more sample objects with properties that reflect real '${type}' objects`,
NOTE: `That each modelVersion has a corresponding fixture file, which defines the previous and current versions.`,
NOTE2: `These fixtures define the before and after state, and are used for both upgrade and rollback testing.`,
HINT: `You can use the template below and create sample objects for ${previous} and ${current} versions.`,
HINT2: `Alternatively, you can copy the contents from older fixture files (if they exist) and adapt them accordingly.`,
IMPORTANT: `The current version fixtures (below) are defining how objects from the previous version fixtures (this array) would look like AFTER upgrading.`,
IMPORTANT2: `They are NOT defining random sample objects with properties that are valid for the current model version.`,
},
],
[current]: [typeTemplate],
Expand Down
Loading
Loading