From d47d57840597b632b619cb86f6151041bc31b171 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 16 Oct 2020 16:45:04 -0400 Subject: [PATCH 01/22] Small refactor, tweak unit tests Refactor some code and unit tests to prepare for following commits. No functionality changes. --- .../migrations/core/document_migrator.test.ts | 1020 +++++++++-------- .../migrations/core/document_migrator.ts | 25 +- .../migrations/core/migrate_raw_docs.test.ts | 44 +- .../saved_objects/serialization/serializer.ts | 30 +- 4 files changed, 571 insertions(+), 548 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 4cc4f696d307c..5dc87041e62dc 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -52,581 +52,585 @@ describe('DocumentMigrator', () => { }; } - it('validates individual migration definitions', () => { - const invalidDefinition = { - kibanaVersion: '3.2.3', - typeRegistry: createRegistry({ - name: 'foo', - migrations: _.noop as any, - }), - log: mockLogger, - }; - expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - /Migration for type foo should be an object/i - ); - }); - - it('validates individual migration semvers', () => { - const invalidDefinition = { - kibanaVersion: '3.2.3', - typeRegistry: createRegistry({ - name: 'foo', - migrations: { - bar: (doc) => doc, - }, - }), - log: mockLogger, - }; - expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - /Expected all properties to be semvers/i - ); - }); - - it('validates the migration function', () => { - const invalidDefinition = { - kibanaVersion: '3.2.3', - typeRegistry: createRegistry({ - name: 'foo', - migrations: { - '1.2.3': 23 as any, - }, - }), - log: mockLogger, - }; - expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - /expected a function, but got 23/i - ); - }); - - it('migrates type and attributes', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'user', - migrations: { - '1.2.3': setAttr('attributes.name', 'Chris'), - }, - }), + describe('validation', () => { + it('validates individual migration definitions', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + migrations: _.noop as any, + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrow( + /Migration for type foo should be an object/i + ); }); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Christopher' }, - migrationVersion: {}, - }); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Chris' }, - migrationVersion: { user: '1.2.3' }, - }); - }); - it(`doesn't mutate the original document`, () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'user', - migrations: { - '1.2.3': (doc) => { - set(doc, 'attributes.name', 'Mike'); - return doc; + it('validates individual migration semvers', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + migrations: { + bar: (doc) => doc, }, - }, - }), + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrow( + /Expected all properties to be semvers/i + ); }); - const originalDoc = { - id: 'me', - type: 'user', - attributes: {}, - migrationVersion: {}, - }; - const migratedDoc = migrator.migrate(originalDoc); - expect(_.get(originalDoc, 'attributes.name')).toBeUndefined(); - expect(_.get(migratedDoc, 'attributes.name')).toBe('Mike'); - }); - - it('migrates root properties', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'acl', - migrations: { - '2.3.5': setAttr('acl', 'admins-only, sucka!'), - }, - }), - }); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - acl: 'anyone', - migrationVersion: {}, - } as SavedObjectUnsanitizedDoc); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - migrationVersion: { acl: '2.3.5' }, - acl: 'admins-only, sucka!', - }); - }); - it('does not apply migrations to unrelated docs', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'aaa', - migrations: { - '1.0.0': setAttr('aaa', 'A'), - }, - }, - { - name: 'bbb', + it('validates the migration function', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', migrations: { - '1.0.0': setAttr('bbb', 'B'), + '1.2.3': 23 as any, }, - }, - { - name: 'ccc', - migrations: { - '1.0.0': setAttr('ccc', 'C'), - }, - } - ), - }); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - migrationVersion: {}, - }); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrow( + /expected a function, but got 23/i + ); }); }); - it('assumes documents w/ undefined migrationVersion are up to date', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { + describe('migration', () => { + it('migrates type and attributes', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ name: 'user', migrations: { - '1.0.0': setAttr('aaa', 'A'), + '1.2.3': setAttr('attributes.name', 'Chris'), }, - }, - { - name: 'bbb', + }), + }); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Christopher' }, + migrationVersion: {}, + }); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Chris' }, + migrationVersion: { user: '1.2.3' }, + }); + }); + + it(`doesn't mutate the original document`, () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'user', migrations: { - '2.3.4': setAttr('bbb', 'B'), + '1.2.3': (doc) => { + set(doc, 'attributes.name', 'Mike'); + return doc; + }, }, - }, - { - name: 'ccc', + }), + }); + const originalDoc = { + id: 'me', + type: 'user', + attributes: {}, + migrationVersion: {}, + }; + const migratedDoc = migrator.migrate(originalDoc); + expect(_.get(originalDoc, 'attributes.name')).toBeUndefined(); + expect(_.get(migratedDoc, 'attributes.name')).toBe('Mike'); + }); + + it('migrates root properties', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'acl', migrations: { - '1.0.0': setAttr('ccc', 'C'), + '2.3.5': setAttr('acl', 'admins-only, sucka!'), }, - } - ), + }), + }); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + acl: 'anyone', + migrationVersion: {}, + } as SavedObjectUnsanitizedDoc); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + migrationVersion: { acl: '2.3.5' }, + acl: 'admins-only, sucka!', + }); }); - const actual = migrator.migrate({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - bbb: 'Shazm', - } as SavedObjectUnsanitizedDoc); - expect(actual).toEqual({ - id: 'me', - type: 'user', - attributes: { name: 'Tyler' }, - bbb: 'Shazm', - migrationVersion: { - user: '1.0.0', - bbb: '2.3.4', - }, + + it('does not apply migrations to unrelated docs', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'aaa', + migrations: { + '1.0.0': setAttr('aaa', 'A'), + }, + }, + { + name: 'bbb', + migrations: { + '1.0.0': setAttr('bbb', 'B'), + }, + }, + { + name: 'ccc', + migrations: { + '1.0.0': setAttr('ccc', 'C'), + }, + } + ), + }); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + migrationVersion: {}, + }); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + }); }); - }); - it('only applies migrations that are more recent than the doc', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '1.2.3': setAttr('attributes.a', 'A'), - '1.2.4': setAttr('attributes.b', 'B'), - '2.0.1': setAttr('attributes.c', 'C'), + it('assumes documents w/ undefined migrationVersion are up to date', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'user', + migrations: { + '1.0.0': setAttr('aaa', 'A'), + }, + }, + { + name: 'bbb', + migrations: { + '2.3.4': setAttr('bbb', 'B'), + }, + }, + { + name: 'ccc', + migrations: { + '1.0.0': setAttr('ccc', 'C'), + }, + } + ), + }); + const actual = migrator.migrate({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + bbb: 'Shazm', + } as SavedObjectUnsanitizedDoc); + expect(actual).toEqual({ + id: 'me', + type: 'user', + attributes: { name: 'Tyler' }, + bbb: 'Shazm', + migrationVersion: { + user: '1.0.0', + bbb: '2.3.4', }, - }), - }); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: { dog: '1.2.3' }, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie', b: 'B', c: 'C' }, - migrationVersion: { dog: '2.0.1' }, + }); }); - }); - it('rejects docs that belong to a newer Kibana instance', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - kibanaVersion: '8.0.1', - }); - expect(() => - migrator.migrate({ + it('only applies migrations that are more recent than the doc', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + migrations: { + '1.2.3': setAttr('attributes.a', 'A'), + '1.2.4': setAttr('attributes.b', 'B'), + '2.0.1': setAttr('attributes.c', 'C'), + }, + }), + }); + const actual = migrator.migrate({ id: 'smelly', type: 'dog', attributes: { name: 'Callie' }, - migrationVersion: { dog: '10.2.0' }, - }) - ).toThrow( - /Document "smelly" has property "dog" which belongs to a more recent version of Kibana \[10\.2\.0\]\. The last known version is \[undefined\]/i - ); - }); - - it('rejects docs that belong to a newer plugin', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dawg', - migrations: { - '1.2.3': setAttr('attributes.a', 'A'), - }, - }), + migrationVersion: { dog: '1.2.3' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie', b: 'B', c: 'C' }, + migrationVersion: { dog: '2.0.1' }, + }); }); - expect(() => - migrator.migrate({ - id: 'fleabag', - type: 'dawg', - attributes: { name: 'Callie' }, - migrationVersion: { dawg: '1.2.4' }, - }) - ).toThrow( - /Document "fleabag" has property "dawg" which belongs to a more recent version of Kibana \[1\.2\.4\]\. The last known version is \[1\.2\.3\]/i - ); - }); - it('applies migrations in order', () => { - let count = 0; - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '2.2.4': setAttr('attributes.b', () => ++count), - '10.0.1': setAttr('attributes.c', () => ++count), - '1.2.3': setAttr('attributes.a', () => ++count), - }, - }), - }); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: { dog: '1.2.0' }, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie', a: 1, b: 2, c: 3 }, - migrationVersion: { dog: '10.0.1' }, + it('rejects docs that belong to a newer Kibana instance', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + kibanaVersion: '8.0.1', + }); + expect(() => + migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: { dog: '10.2.0' }, + }) + ).toThrow( + /Document "smelly" has property "dog" which belongs to a more recent version of Kibana \[10\.2\.0\]\. The last known version is \[undefined\]/i + ); }); - }); - it('allows props to be added', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'animal', + it('rejects docs that belong to a newer plugin', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dawg', migrations: { - '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), + '1.2.3': setAttr('attributes.a', 'A'), }, - }, - { + }), + }); + expect(() => + migrator.migrate({ + id: 'fleabag', + type: 'dawg', + attributes: { name: 'Callie' }, + migrationVersion: { dawg: '1.2.4' }, + }) + ).toThrow( + /Document "fleabag" has property "dawg" which belongs to a more recent version of Kibana \[1\.2\.4\]\. The last known version is \[1\.2\.3\]/i + ); + }); + + it('applies migrations in order', () => { + let count = 0; + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ name: 'dog', migrations: { - '2.2.4': setAttr('animal', 'Doggie'), + '2.2.4': setAttr('attributes.b', () => ++count), + '10.0.1': setAttr('attributes.c', () => ++count), + '1.2.3': setAttr('attributes.a', () => ++count), }, - } - ), - }); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: { dog: '1.2.0' }, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - animal: 'Animal: Doggie', - migrationVersion: { animal: '1.0.0', dog: '2.2.4' }, + }), + }); + const actual = migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: { dog: '1.2.0' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie', a: 1, b: 2, c: 3 }, + migrationVersion: { dog: '10.0.1' }, + }); }); - }); - it('allows props to be renamed', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'animal', - migrations: { - '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), - '3.2.1': renameAttr('animal', 'dawg'), + it('allows props to be added', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'animal', + migrations: { + '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), + }, }, - }, - { - name: 'dawg', - migrations: { - '2.2.4': renameAttr('dawg', 'animal'), - '3.2.0': setAttr('dawg', (name: string) => `Dawg3.x: ${name}`), + { + name: 'dog', + migrations: { + '2.2.4': setAttr('animal', 'Doggie'), + }, + } + ), + }); + const actual = migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: { dog: '1.2.0' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + animal: 'Animal: Doggie', + migrationVersion: { animal: '1.0.0', dog: '2.2.4' }, + }); + }); + + it('allows props to be renamed', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'animal', + migrations: { + '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), + '3.2.1': renameAttr('animal', 'dawg'), + }, }, - } - ), + { + name: 'dawg', + migrations: { + '2.2.4': renameAttr('dawg', 'animal'), + '3.2.0': setAttr('dawg', (name: string) => `Dawg3.x: ${name}`), + }, + } + ), + }); + const actual = migrator.migrate({ + id: 'smelly', + type: 'foo', + attributes: { name: 'Callie' }, + dawg: 'Yo', + migrationVersion: {}, + } as SavedObjectUnsanitizedDoc); + expect(actual).toEqual({ + id: 'smelly', + type: 'foo', + attributes: { name: 'Callie' }, + dawg: 'Dawg3.x: Animal: Yo', + migrationVersion: { animal: '3.2.1', dawg: '3.2.0' }, + }); }); - const actual = migrator.migrate({ - id: 'smelly', - type: 'foo', - attributes: { name: 'Callie' }, - dawg: 'Yo', - migrationVersion: {}, - } as SavedObjectUnsanitizedDoc); - expect(actual).toEqual({ - id: 'smelly', - type: 'foo', - attributes: { name: 'Callie' }, - dawg: 'Dawg3.x: Animal: Yo', - migrationVersion: { animal: '3.2.1', dawg: '3.2.0' }, + + it('allows changing type', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'cat', + migrations: { + '1.0.0': setAttr('attributes.name', (name: string) => `Kitty ${name}`), + }, + }, + { + name: 'dog', + migrations: { + '2.2.4': setAttr('type', 'cat'), + }, + } + ), + }); + const actual = migrator.migrate({ + id: 'smelly', + type: 'dog', + attributes: { name: 'Callie' }, + migrationVersion: {}, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Kitty Callie' }, + migrationVersion: { dog: '2.2.4', cat: '1.0.0' }, + }); }); - }); - it('allows changing type', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { + it('disallows updating a migrationVersion prop to a lower version', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ name: 'cat', migrations: { - '1.0.0': setAttr('attributes.name', (name: string) => `Kitty ${name}`), + '1.0.0': setAttr('migrationVersion.foo', '3.2.1'), }, - }, - { - name: 'dog', + }), + }); + + expect(() => + migrator.migrate({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Boo' }, + migrationVersion: { foo: '4.5.6' }, + }) + ).toThrow( + /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to 3.2.1./ + ); + }); + + it('disallows removing a migrationVersion prop', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'cat', migrations: { - '2.2.4': setAttr('type', 'cat'), + '1.0.0': setAttr('migrationVersion', {}), }, - } - ), - }); - const actual = migrator.migrate({ - id: 'smelly', - type: 'dog', - attributes: { name: 'Callie' }, - migrationVersion: {}, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Kitty Callie' }, - migrationVersion: { dog: '2.2.4', cat: '1.0.0' }, + }), + }); + expect(() => + migrator.migrate({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Boo' }, + migrationVersion: { foo: '4.5.6' }, + }) + ).toThrow( + /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to undefined./ + ); }); - }); - it('disallows updating a migrationVersion prop to a lower version', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion.foo', '3.2.1'), - }, - }), + it('allows updating a migrationVersion prop to a later version', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'cat', + migrations: { + '1.0.0': setAttr('migrationVersion.cat', '2.9.1'), + '2.0.0': () => { + throw new Error('POW!'); + }, + '2.9.1': () => { + throw new Error('BANG!'); + }, + '3.0.0': setAttr('attributes.name', 'Shiny'), + }, + }), + }); + const actual = migrator.migrate({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Boo' }, + migrationVersion: { cat: '0.5.6' }, + }); + expect(actual).toEqual({ + id: 'smelly', + type: 'cat', + attributes: { name: 'Shiny' }, + migrationVersion: { cat: '3.0.0' }, + }); }); - expect(() => - migrator.migrate({ + it('allows adding props to migrationVersion', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'cat', + migrations: { + '1.0.0': setAttr('migrationVersion.foo', '5.6.7'), + }, + }), + }); + const actual = migrator.migrate({ id: 'smelly', type: 'cat', attributes: { name: 'Boo' }, - migrationVersion: { foo: '4.5.6' }, - }) - ).toThrow( - /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to 3.2.1./ - ); - }); - - it('disallows removing a migrationVersion prop', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion', {}), - }, - }), - }); - expect(() => - migrator.migrate({ + migrationVersion: {}, + }); + expect(actual).toEqual({ id: 'smelly', type: 'cat', attributes: { name: 'Boo' }, - migrationVersion: { foo: '4.5.6' }, - }) - ).toThrow( - /Migration "cat v 1.0.0" attempted to downgrade "migrationVersion.foo" from 4.5.6 to undefined./ - ); - }); - - it('allows updating a migrationVersion prop to a later version', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion.cat', '2.9.1'), - '2.0.0': () => { - throw new Error('POW!'); - }, - '2.9.1': () => { - throw new Error('BANG!'); - }, - '3.0.0': setAttr('attributes.name', 'Shiny'), - }, - }), - }); - const actual = migrator.migrate({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Boo' }, - migrationVersion: { cat: '0.5.6' }, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Shiny' }, - migrationVersion: { cat: '3.0.0' }, - }); - }); - - it('allows adding props to migrationVersion', () => { - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'cat', - migrations: { - '1.0.0': setAttr('migrationVersion.foo', '5.6.7'), - }, - }), - }); - const actual = migrator.migrate({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Boo' }, - migrationVersion: {}, - }); - expect(actual).toEqual({ - id: 'smelly', - type: 'cat', - attributes: { name: 'Boo' }, - migrationVersion: { cat: '1.0.0', foo: '5.6.7' }, + migrationVersion: { cat: '1.0.0', foo: '5.6.7' }, + }); }); - }); - it('logs the document and transform that failed', () => { - const log = mockLogger; - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '1.2.3': () => { - throw new Error('Dang diggity!'); + it('logs the document and transform that failed', () => { + const log = mockLogger; + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + migrations: { + '1.2.3': () => { + throw new Error('Dang diggity!'); + }, }, - }, - }), - log, + }), + log, + }); + const failedDoc = { + id: 'smelly', + type: 'dog', + attributes: {}, + migrationVersion: {}, + }; + try { + migrator.migrate(_.cloneDeep(failedDoc)); + expect('Did not throw').toEqual('But it should have!'); + } catch (error) { + expect(error.message).toMatch(/Dang diggity!/); + const warning = loggingSystemMock.collect(mockLoggerFactory).warn[0][0]; + expect(warning).toContain(JSON.stringify(failedDoc)); + expect(warning).toContain('dog:1.2.3'); + } }); - const failedDoc = { - id: 'smelly', - type: 'dog', - attributes: {}, - migrationVersion: {}, - }; - try { - migrator.migrate(_.cloneDeep(failedDoc)); - expect('Did not throw').toEqual('But it should have!'); - } catch (error) { - expect(error.message).toMatch(/Dang diggity!/); - const warning = loggingSystemMock.collect(mockLoggerFactory).warn[0][0]; - expect(warning).toContain(JSON.stringify(failedDoc)); - expect(warning).toContain('dog:1.2.3'); - } - }); - it('logs message in transform function', () => { - const logTestMsg = '...said the joker to the thief'; - const migrator = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - migrations: { - '1.2.3': (doc, { log }) => { - log.info(logTestMsg); - log.warning(logTestMsg); - return doc; + it('logs message in transform function', () => { + const logTestMsg = '...said the joker to the thief'; + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + migrations: { + '1.2.3': (doc, { log }) => { + log.info(logTestMsg); + log.warning(logTestMsg); + return doc; + }, }, - }, - }), - log: mockLogger, + }), + log: mockLogger, + }); + const doc = { + id: 'joker', + type: 'dog', + attributes: {}, + migrationVersion: {}, + }; + migrator.migrate(doc); + expect(loggingSystemMock.collect(mockLoggerFactory).info[0][0]).toEqual(logTestMsg); + expect(loggingSystemMock.collect(mockLoggerFactory).warn[1][0]).toEqual(logTestMsg); }); - const doc = { - id: 'joker', - type: 'dog', - attributes: {}, - migrationVersion: {}, - }; - migrator.migrate(doc); - expect(loggingSystemMock.collect(mockLoggerFactory).info[0][0]).toEqual(logTestMsg); - expect(loggingSystemMock.collect(mockLoggerFactory).warn[1][0]).toEqual(logTestMsg); - }); - test('extracts the latest migration version info', () => { - const { migrationVersion } = new DocumentMigrator({ - ...testOpts(), - typeRegistry: createRegistry( - { - name: 'aaa', - migrations: { - '1.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, - '10.4.0': (doc: SavedObjectUnsanitizedDoc) => doc, - '2.2.1': (doc: SavedObjectUnsanitizedDoc) => doc, + test('extracts the latest migration version info', () => { + const { migrationVersion } = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'aaa', + migrations: { + '1.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, + '10.4.0': (doc: SavedObjectUnsanitizedDoc) => doc, + '2.2.1': (doc: SavedObjectUnsanitizedDoc) => doc, + }, }, - }, - { - name: 'bbb', - migrations: { - '3.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, - '2.0.0': (doc: SavedObjectUnsanitizedDoc) => doc, - }, - } - ), - }); + { + name: 'bbb', + migrations: { + '3.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, + '2.0.0': (doc: SavedObjectUnsanitizedDoc) => doc, + }, + } + ), + }); - expect(migrationVersion).toEqual({ - aaa: '10.4.0', - bbb: '3.2.3', + expect(migrationVersion).toEqual({ + aaa: '10.4.0', + bbb: '3.2.3', + }); }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index ccda72702b53c..7298988a47b7e 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -82,13 +82,15 @@ interface DocumentMigratorOptions { interface ActiveMigrations { [type: string]: { latestVersion: string; - transforms: Array<{ - version: string; - transform: TransformFn; - }>; + transforms: Transform[]; }; } +interface Transform { + version: string; + transform: TransformFn; +} + /** * Manages migration of individual documents. */ @@ -151,7 +153,7 @@ export class DocumentMigrator implements VersionedTransformer { } /** - * 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. @@ -178,14 +180,15 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry) { } registry.getAllTypes().forEach((type) => { - if (type.migrations) { + const { name, migrations } = type; + if (migrations) { assertObject( type.migrations, - `Migration for type ${type.name} should be an object like { '2.0.0': (doc) => doc }.` + `Migration for type ${name} should be an object like { '2.0.0': (doc) => doc }.` ); - Object.entries(type.migrations).forEach(([version, fn]) => { - assertValidSemver(version, type.name); - assertValidTransform(fn, version, type.name); + Object.entries(migrations).forEach(([version, fn]) => { + assertValidSemver(version, name); + assertValidTransform(fn, version, name); }); } }); @@ -206,7 +209,7 @@ function buildActiveMigrations( .filter((type) => type.migrations && Object.keys(type.migrations).length > 0) .reduce((migrations, type) => { const transforms = Object.entries(type.migrations!) - .map(([version, transform]) => ({ + .map(([version, transform]) => ({ version, transform: wrapWithTry(version, type.name, transform, log), })) diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 83dc042d2b96b..dd95927b42b59 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -26,7 +26,9 @@ import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { - const transform = jest.fn((doc: any) => set(doc, 'attributes.name', 'HOI!')); + const transform = jest.fn((doc: any) => + set(_.cloneDeep(doc), 'attributes.name', 'HOI!') + ); const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, @@ -48,7 +50,23 @@ 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 () => { @@ -74,19 +92,15 @@ 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); }); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 145dd286c1ca8..202546b78805c 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -42,17 +42,16 @@ 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 */ - 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) { + const { _id, _source } = doc; + const { type, namespace } = _source; + if (!type) { + return false; + } + const { idMatchesPrefix } = this.parseIdPrefix(namespace, type, _id); + return idMatchesPrefix && _source.hasOwnProperty(type); } /** @@ -137,15 +136,18 @@ export class SavedObjectsSerializer { assertNonEmptyString(id, 'document id'); assertNonEmptyString(type, 'saved object type'); + const { prefix, idMatchesPrefix } = this.parseIdPrefix(namespace, type, id); + return idMatchesPrefix ? id.slice(prefix.length) : id; + } + + private parseIdPrefix(namespace: string | undefined, type: string, id: string) { const namespacePrefix = namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; const prefix = `${namespacePrefix}${type}:`; - if (!id.startsWith(prefix)) { - return id; - } + const idMatchesPrefix = id.startsWith(prefix); - return id.slice(prefix.length); + return { prefix, idMatchesPrefix }; } } From 1b0165bd020b4f63ab1985c15406dfdf9f16aa4d Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 19 Oct 2020 16:22:30 -0400 Subject: [PATCH 02/22] Add serializer unit tests Test cases were missing for multi-namespace types. Added those tests. No functionality changes. --- .../serialization/serializer.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index e5f0e8abd3b71..9768cbff3c43f 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -968,6 +968,99 @@ 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 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( @@ -1087,6 +1180,18 @@ describe('#generateRawId', () => { }); }); + describe('multi-namespace type with a namespace', () => { + test(`generates an id if none is specified and doesn't prefix namespace`, () => { + const id = multiNamespaceSerializer.generateRawId('foo', 'goodbye'); + expect(id).toMatch(/^goodbye\:[\w-]+$/); + }); + + 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(`generates an id if none is specified and doesn't prefix namespace`, () => { const id = namespaceAgnosticSerializer.generateRawId('foo', 'goodbye'); From e27e6be8e0f1078135774b1dfe572882a2a53e5a Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Sun, 18 Oct 2020 23:09:28 -0400 Subject: [PATCH 03/22] Tweak migration loop 1. The migration loop no longer resets if the object does not have a [type] field. Objects never have this field after being deserialized from their raw document format, this functionality appears to be unnecessary and can cause unexpected behavior (though migrations still worked because the loop would get restarted). 2. Changed one unit test case that depended upon the aforementioned condition. This appeared to be an invalid test case which would not actually be encountered in a real world scenario, since it relied on an object containing a [type] field. 3. The migration loop now resets if an object's migrationVersion has explicitly been increased by a transform function. This is expected behavior and a unit test exercises it, but it was a side effect of the aforementioned condition. --- .../migrations/core/document_migrator.test.ts | 34 +++++++------------ .../migrations/core/document_migrator.ts | 10 ++++-- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 5dc87041e62dc..86fad0ee28c73 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -382,36 +382,26 @@ describe('DocumentMigrator', () => { it('allows props to be renamed', () => { const migrator = new DocumentMigrator({ ...testOpts(), - typeRegistry: createRegistry( - { - name: 'animal', - migrations: { - '1.0.0': setAttr('animal', (name: string) => `Animal: ${name}`), - '3.2.1': renameAttr('animal', 'dawg'), - }, + typeRegistry: createRegistry({ + name: 'dog', + migrations: { + '1.0.0': setAttr('attributes.name', (name: string) => `Name: ${name}`), + '1.0.1': renameAttr('attributes.name', 'attributes.title'), + '1.0.2': setAttr('attributes.title', (name: string) => `Title: ${name}`), }, - { - name: 'dawg', - migrations: { - '2.2.4': renameAttr('dawg', 'animal'), - '3.2.0': setAttr('dawg', (name: string) => `Dawg3.x: ${name}`), - }, - } - ), + }), }); const actual = migrator.migrate({ id: 'smelly', - type: 'foo', + type: 'dog', attributes: { name: 'Callie' }, - dawg: 'Yo', migrationVersion: {}, - } as SavedObjectUnsanitizedDoc); + }); expect(actual).toEqual({ id: 'smelly', - type: 'foo', - attributes: { name: 'Callie' }, - dawg: 'Dawg3.x: Animal: Yo', - migrationVersion: { animal: '3.2.1', dawg: '3.2.0' }, + type: 'dog', + attributes: { title: 'Title: Name: Callie' }, + migrationVersion: { dog: '1.0.2' }, }); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 7298988a47b7e..78032fa4b21a1 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -361,14 +361,20 @@ function migrateProp( ): SavedObjectUnsanitizedDoc { const originalType = doc.type; let migrationVersion = _.clone(doc.migrationVersion) || {}; - const typeChanged = () => !doc.hasOwnProperty(prop) || doc.type !== originalType; for (const { version, transform } 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; + } + doc = transform(doc); migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version); doc.migrationVersion = _.clone(migrationVersion); - if (typeChanged()) { + if (doc.type !== originalType) { + // the transform function changed the object's type; break out of the loop break; } } From 02db847e9a861894670868e6624fa27bb4af4fe8 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 20 Oct 2020 12:31:29 -0400 Subject: [PATCH 04/22] Do not allow migrations higher than the current Kibana version --- .../migrations/core/document_migrator.test.ts | 18 +++++++++++++++++- .../migrations/core/document_migrator.ts | 9 +++++++-- .../server/saved_objects/migrations/types.ts | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 86fad0ee28c73..52f13d7583c1e 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -67,7 +67,7 @@ describe('DocumentMigrator', () => { ); }); - it('validates individual migration semvers', () => { + it('validates individual migrations are valid semvers', () => { const invalidDefinition = { kibanaVersion: '3.2.3', typeRegistry: createRegistry({ @@ -83,6 +83,22 @@ describe('DocumentMigrator', () => { ); }); + it('validates individual migrations are not greater than the current Kibana version', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + migrations: { + '3.2.4': (doc) => doc, + }, + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( + `Invalid migration for type foo. Property '3.2.4' cannot be greater than the current Kibana version '3.2.3'.` + ); + }); + it('validates the migration function', () => { const invalidDefinition = { kibanaVersion: '3.2.3', diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 78032fa4b21a1..c9009cedd4a5a 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -116,7 +116,7 @@ export class DocumentMigrator implements VersionedTransformer { * @memberof DocumentMigrator */ constructor({ typeRegistry, kibanaVersion, log }: DocumentMigratorOptions) { - validateMigrationDefinition(typeRegistry); + validateMigrationDefinition(typeRegistry, kibanaVersion); this.migrations = buildActiveMigrations(typeRegistry, log); this.transformDoc = buildDocumentTransform({ @@ -158,7 +158,7 @@ export class DocumentMigrator implements VersionedTransformer { * 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) { function assertObject(obj: any, prefix: string) { if (!obj || typeof obj !== 'object') { throw new Error(`${prefix} Got ${obj}.`); @@ -171,6 +171,11 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry) { `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) { diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts index 5e55a34193a96..f23a429d2d8ac 100644 --- a/src/core/server/saved_objects/migrations/types.ts +++ b/src/core/server/saved_objects/migrations/types.ts @@ -72,7 +72,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. From c7b6d15305da0c78f589f6d058b8b3e697a942c3 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 20 Oct 2020 12:42:40 -0400 Subject: [PATCH 05/22] Add "conversion" transforms for object types These can only be applied using the `convertToMultiNamespaceTypeVersion` field when defining the object type. Doing so will cause the document migrator to apply a special transform function to convert the document from a single-namespace type to a multi-namespace type in the specified version. Note that this transform is applied before any consumer-defined migrations in the same version. --- src/core/server/index.ts | 1 + src/core/server/saved_objects/index.ts | 1 + .../migrations/core/__mocks__/index.ts | 24 +++ .../migrations/core/document_migrator.test.ts | 204 ++++++++++++++++++ .../migrations/core/document_migrator.ts | 172 +++++++++++++-- .../migrations/core/index_migrator.test.ts | 15 +- .../migrations/core/index_migrator.ts | 2 +- .../migrations/core/migrate_raw_docs.ts | 11 +- .../saved_objects/serialization/index.ts | 1 + .../serialization/serializer.test.ts | 37 ++++ .../saved_objects/serialization/serializer.ts | 49 +++-- .../saved_objects/serialization/types.ts | 14 ++ src/core/server/saved_objects/types.ts | 35 +++ 13 files changed, 524 insertions(+), 42 deletions(-) create mode 100644 src/core/server/saved_objects/migrations/core/__mocks__/index.ts diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 9e654ea1e2303..c098c08d87d84 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -274,6 +274,7 @@ export { SavedObjectMigrationContext, SavedObjectsMigrationLogger, SavedObjectsRawDoc, + SavedObjectsRawDocParseOptions, SavedObjectSanitizedDoc, SavedObjectUnsanitizedDoc, SavedObjectsRepositoryFactory, diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index f2bae29c4743b..2894d0b90cfc7 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -30,6 +30,7 @@ export { export { SavedObjectsSerializer, SavedObjectsRawDoc, + SavedObjectsRawDocParseOptions, SavedObjectSanitizedDoc, SavedObjectUnsanitizedDoc, } from './serialization'; diff --git a/src/core/server/saved_objects/migrations/core/__mocks__/index.ts b/src/core/server/saved_objects/migrations/core/__mocks__/index.ts new file mode 100644 index 0000000000000..4dbc29084ad7b --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/__mocks__/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockUuidv5 = jest.fn().mockReturnValue('uuidv5'); +Object.defineProperty(mockUuidv5, 'DNS', { value: 'DNSUUID', writable: false }); +jest.mock('uuid/v5', () => mockUuidv5); + +export { mockUuidv5 }; diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 52f13d7583c1e..a0934be206a30 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { mockUuidv5 } from './__mocks__'; import { set } from '@elastic/safer-lodash-set'; import _ from 'lodash'; import { SavedObjectUnsanitizedDoc } from '../../serialization'; @@ -43,6 +44,10 @@ const createRegistry = (...types: Array>) => { return registry; }; +beforeEach(() => { + mockUuidv5.mockClear(); +}); + describe('DocumentMigrator', () => { function testOpts() { return { @@ -114,6 +119,65 @@ describe('DocumentMigrator', () => { /expected a function, but got 23/i ); }); + + it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple'`, () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: 'bar', + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrow( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple', but got 'single'.` + ); + }); + + it(`validates convertToMultiNamespaceTypeVersion must be a semver`, () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: 'bar', + namespaceType: 'multiple', + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrow( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected value to be a semver, but got 'bar'.` + ); + }); + + it('validates convertToMultiNamespaceTypeVersion is not greater than the current Kibana version', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: '3.2.4', + namespaceType: 'multiple', + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Value '3.2.4' cannot be greater than the current Kibana version '3.2.3'.` + ); + }); + + it('validates convertToMultiNamespaceTypeVersion is not used on a patch version', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: '3.1.1', + namespaceType: 'multiple', + }), + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Value '3.1.1' cannot be used on a patch version (must be like 'x.y.0').` + ); + }); }); describe('migration', () => { @@ -638,6 +702,146 @@ describe('DocumentMigrator', () => { bbb: '3.2.3', }); }); + + describe('conversion to multi-namespace type', () => { + it('assumes documents w/ undefined migrationVersion are up to date', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + // no migration transforms are defined, the migrationVersion will be derived from 'convertToMultiNamespaceTypeVersion' + ), + }); + const obj = { + id: 'mischievous', + type: 'dog', + attributes: { name: 'Ann' }, + } as SavedObjectUnsanitizedDoc; + const actual = migrator.migrateAndConvert(obj); + expect(actual).toEqual({ + id: 'mischievous', + type: 'dog', + attributes: { name: 'Ann' }, + migrationVersion: { dog: '1.0.0' }, + // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario + }); + }); + + it('skips conversion transforms when using `migrate`', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '1.0.0', + }), + }); + const obj = { + id: 'cowardly', + type: 'dog', + attributes: { name: 'Leslie' }, + migrationVersion: {}, + }; + const actual = migrator.migrate(obj); + expect(actual).toEqual({ + id: 'cowardly', + type: 'dog', + attributes: { name: 'Leslie' }, + migrationVersion: { dog: '1.0.0' }, + // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario + }); + }); + + describe('correctly applies conversion transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '1.0.0', + }), + }); + const obj = { + id: 'loud', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: {}, + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual({ + id: 'loud', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: { dog: '1.0.0' }, + namespaces: ['default'], + }); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:dog:loud', 'DNSUUID'); + expect(actual).toEqual({ + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: { dog: '1.0.0' }, + namespaces: ['foo-namespace'], + originId: 'loud', + }); + }); + }); + + describe('correctly applies conversion and migration transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry({ + name: 'dog', + namespaceType: 'multiple', + migrations: { + '1.0.0': setAttr('migrationVersion.dog', '2.0.0'), + '2.0.0': (doc) => doc, // noop + }, + convertToMultiNamespaceTypeVersion: '1.0.0', // the conversion transform occurs before the migration transform above + }), + }); + const obj = { + id: 'hungry', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: {}, + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual({ + id: 'hungry', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: { dog: '2.0.0' }, + namespaces: ['default'], + }); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:dog:hungry', 'DNSUUID'); + expect(actual).toEqual({ + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: { dog: '2.0.0' }, + namespaces: ['foo-namespace'], + originId: 'hungry', + }); + }); + }); + }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index c9009cedd4a5a..41e401b23fd08 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -61,17 +61,29 @@ */ 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, SavedObjectsType } from '../../types'; import { MigrationLogger } from './migration_logger'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectMigrationFn } from '../types'; +import { DEFAULT_NAMESPACE_STRING } from '../../service/lib/utils'; -export type TransformFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; +export type MigrateFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; +export type MigrateAndConvertFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; + +type TransformFn = ( + doc: SavedObjectUnsanitizedDoc, + options?: TransformOptions +) => SavedObjectUnsanitizedDoc; + +interface TransformOptions { + convertTypes?: boolean; +} interface DocumentMigratorOptions { kibanaVersion: string; @@ -88,7 +100,8 @@ interface ActiveMigrations { interface Transform { version: string; - transform: TransformFn; + transform: (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; + transformType: 'migrate' | 'convert'; } /** @@ -96,7 +109,8 @@ interface Transform { */ export interface VersionedTransformer { migrationVersion: SavedObjectsMigrationVersion; - migrate: TransformFn; + migrate: MigrateFn; + migrateAndConvert: MigrateAndConvertFn; } /** @@ -150,6 +164,21 @@ export class DocumentMigrator implements VersionedTransformer { const clonedDoc = _.cloneDeep(doc); return this.transformDoc(clonedDoc); }; + + /** + * Migrates a document to the latest version and applies type conversions if applicable. + * + * @param {SavedObjectUnsanitizedDoc} doc + * @returns {SavedObjectUnsanitizedDoc} + * @memberof DocumentMigrator + */ + public migrateAndConvert = (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc => { + // 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); + return this.transformDoc(clonedDoc, { convertTypes: true }); + }; } /** @@ -184,8 +213,32 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry, kibanaV } } + function assertValidConvertToMultiNamespaceType( + namespaceType: string, + 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.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) => { - const { name, migrations } = type; + const { name, migrations, convertToMultiNamespaceTypeVersion, namespaceType } = type; if (migrations) { assertObject( type.migrations, @@ -196,6 +249,13 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry, kibanaV assertValidTransform(fn, version, name); }); } + if (convertToMultiNamespaceTypeVersion) { + assertValidConvertToMultiNamespaceType( + namespaceType, + convertToMultiNamespaceTypeVersion, + name + ); + } }); } @@ -211,14 +271,23 @@ function buildActiveMigrations( ): ActiveMigrations { return typeRegistry .getAllTypes() - .filter((type) => type.migrations && Object.keys(type.migrations).length > 0) + .filter( + (type) => + (type.migrations && Object.keys(type.migrations).length > 0) || + type.convertToMultiNamespaceTypeVersion + ) .reduce((migrations, type) => { - const transforms = Object.entries(type.migrations!) - .map(([version, transform]) => ({ + const migrationTransforms = Object.entries(type.migrations!).map( + ([version, transform]) => ({ version, transform: wrapWithTry(version, type.name, transform, log), - })) - .sort((a, b) => Semver.compare(a.version, b.version)); + transformType: 'migrate', + }) + ); + const conversionTransforms = getConversionTransforms(type); + const transforms = [...migrationTransforms, ...conversionTransforms].sort( + transformComparator + ); return { ...migrations, [type.name]: { @@ -238,9 +307,13 @@ function buildDocumentTransform({ kibanaVersion: string; migrations: ActiveMigrations; }): TransformFn { - return function transformAndValidate(doc: SavedObjectUnsanitizedDoc) { + return function transformAndValidate( + doc: SavedObjectUnsanitizedDoc, + options: TransformOptions = {} + ) { + const { convertTypes = false } = options; const result = doc.migrationVersion - ? applyMigrations(doc, migrations) + ? applyMigrations(doc, migrations, convertTypes) : markAsUpToDate(doc, migrations); // In order to keep tests a bit more stable, we won't @@ -254,13 +327,17 @@ function buildDocumentTransform({ }; } -function applyMigrations(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations) { +function applyMigrations( + doc: SavedObjectUnsanitizedDoc, + migrations: ActiveMigrations, + convertTypes: boolean +) { while (true) { const prop = nextUnmigratedProp(doc, migrations); if (!prop) { return doc; } - doc = migrateProp(doc, prop, migrations); + doc = migrateProp(doc, prop, migrations, convertTypes); } } @@ -294,6 +371,63 @@ function markAsUpToDate(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrat }; } +/** + * 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 convertType(doc: SavedObjectUnsanitizedDoc) { + const { namespace, ...otherAttrs } = doc; + + // If this object exists in the default namespace, return it with the appropriate `namespaces` field without changing its ID. + if (namespace === undefined) { + return { ...otherAttrs, namespaces: [DEFAULT_NAMESPACE_STRING] }; + } + + const { id: originId, type } = otherAttrs; + // Deterministically generate a new ID for this object; the uuidv5 namespace constant (uuidv5.DNS) is arbitrary + const id = uuidv5(`${namespace}:${type}:${originId}`, uuidv5.DNS); + return { ...otherAttrs, id, originId, namespaces: [namespace] }; +} + +/** + * 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: convertType, + transformType: 'convert', + }, + ]; +} + +/** + * Transforms are sorted in ascending order by version. One version may contain multiple transforms; 'convert' transforms always run first, + * and 'migrate' transforms always run last. This is because '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; + } + } + return 0; +} + /** * If a specific transform function fails, this tacks on a bit of information * about the document and transform that caused the failure. @@ -362,19 +496,23 @@ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMi function migrateProp( doc: SavedObjectUnsanitizedDoc, prop: string, - migrations: ActiveMigrations + migrations: ActiveMigrations, + convertTypes: boolean ): SavedObjectUnsanitizedDoc { const originalType = doc.type; let migrationVersion = _.clone(doc.migrationVersion) || {}; - for (const { version, transform } of applicableTransforms(migrations, doc, prop)) { + 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; } - doc = transform(doc); + if (transformType === 'migrate' || convertTypes) { + // migrate transforms are always applied, but conversion transforms are only applied when Kibana is upgraded + doc = transform(doc); + } migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version); doc.migrationVersion = _.clone(migrationVersion); diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 13f771c16bc67..5654efafcaa09 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -42,6 +42,7 @@ describe('IndexMigrator', () => { documentMigrator: { migrationVersion: {}, migrate: _.identity, + migrateAndConvert: _.identity, }, serializer: new SavedObjectsSerializer(new SavedObjectTypeRegistry()), }; @@ -317,7 +318,7 @@ describe('IndexMigrator', () => { test('transforms all docs from the original index', async () => { let count = 0; const { client } = testOpts; - const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { + const migrateAndConvertDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { return { ...doc, attributes: { name: ++count }, @@ -326,7 +327,8 @@ describe('IndexMigrator', () => { testOpts.documentMigrator = { migrationVersion: { foo: '1.2.3' }, - migrate: migrateDoc, + migrate: jest.fn(), + migrateAndConvert: migrateAndConvertDoc, }; withIndex(client, { @@ -340,14 +342,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' }, @@ -372,13 +374,14 @@ 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: migrateDoc, + migrate: jest.fn(), + migrateAndConvert: migrateAndConvertDoc, }; withIndex(client, { diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index ceca27fa87723..98409bb7ad48c 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -194,7 +194,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) ); } } diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index 5a5048d8ad88f..c6cea07b9fc09 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -26,7 +26,7 @@ import { SavedObjectsSerializer, SavedObjectUnsanitizedDoc, } from '../../serialization'; -import { TransformFn } from './document_migrator'; +import { MigrateAndConvertFn } from './document_migrator'; import { SavedObjectsMigrationLogger } from '.'; /** @@ -39,15 +39,16 @@ import { SavedObjectsMigrationLogger } from '.'; */ export async function migrateRawDocs( serializer: SavedObjectsSerializer, - migrateDoc: TransformFn, + migrateDoc: MigrateAndConvertFn, rawDocs: SavedObjectsRawDoc[], log: SavedObjectsMigrationLogger ): Promise { const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc); const processedDocs = []; for (const raw of rawDocs) { - if (serializer.isRawSavedObject(raw)) { - const savedObject = serializer.rawToSavedObject(raw); + const options = { flexible: true }; + if (serializer.isRawSavedObject(raw, options)) { + const savedObject = serializer.rawToSavedObject(raw, options); savedObject.migrationVersion = savedObject.migrationVersion || {}; processedDocs.push( serializer.savedObjectToRaw({ @@ -74,7 +75,7 @@ export async function migrateRawDocs( * work in between each transform. */ function transformNonBlocking( - transform: TransformFn + transform: MigrateAndConvertFn ): (doc: SavedObjectUnsanitizedDoc) => Promise { // promises aren't enough to unblock the event loop return (doc: SavedObjectUnsanitizedDoc) => diff --git a/src/core/server/saved_objects/serialization/index.ts b/src/core/server/saved_objects/serialization/index.ts index 812a0770ad988..217f1ecba995c 100644 --- a/src/core/server/saved_objects/serialization/index.ts +++ b/src/core/server/saved_objects/serialization/index.ts @@ -26,6 +26,7 @@ export { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, SavedObjectsRawDoc, + SavedObjectsRawDocParseOptions, SavedObjectsRawDocSource, } from './types'; export { SavedObjectsSerializer } from './serializer'; diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 9768cbff3c43f..132c4102fb860 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -423,6 +423,26 @@ describe('#rawToSavedObject', () => { test(`doesn't copy _source.namespace to namespace`, () => { expect(actual).not.toHaveProperty('namespace'); }); + + describe('with "flexible" option enabled', () => { + const options = { flexible: true }; + + test(`removes type prefix from _id`, () => { + const _actual = multiNamespaceSerializer.rawToSavedObject(raw, options); + expect(_actual).toHaveProperty('id', 'bar'); + }); + + test(`removes type and namespace prefix from _id`, () => { + const _id = `${raw._source.namespace}:${raw._id}`; + const _actual = multiNamespaceSerializer.rawToSavedObject({ ...raw, _id }, options); + expect(_actual).toHaveProperty('id', 'bar'); + }); + + test(`copies _source.namespace to namespace if "flexible" option is enabled`, () => { + const _actual = multiNamespaceSerializer.rawToSavedObject(raw, options); + expect(_actual).toHaveProperty('namespace', 'baz'); + }); + }); }); describe('multi-namespace type with namespaces', () => { @@ -1008,6 +1028,23 @@ describe('#isRawSavedObject', () => { ).toBeFalsy(); }); + test('is true if the id is prefixed with type and namespace, and the "flexible" option is enabled', () => { + const options = { flexible: true }; + 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({ diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 202546b78805c..e88b48d1fd6bd 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -20,7 +20,11 @@ import uuid from 'uuid'; 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 @@ -43,23 +47,30 @@ export class SavedObjectsSerializer { * Determines whether or not the raw document can be converted to a saved object. * * @param {SavedObjectsRawDoc} doc - The raw ES document to be tested + * @param {SavedObjectsRawDocParseOptions} options - Options for parsing the raw document. */ - public isRawSavedObject(doc: SavedObjectsRawDoc) { + public isRawSavedObject(doc: SavedObjectsRawDoc, options: SavedObjectsRawDocParseOptions = {}) { + const { flexible = false } = options; const { _id, _source } = doc; const { type, namespace } = _source; if (!type) { return false; } - const { idMatchesPrefix } = this.parseIdPrefix(namespace, type, _id); + const { idMatchesPrefix } = this.parseIdPrefix(namespace, type, _id, flexible); 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 { flexible = false } = options; const { _id, _source, _seq_no, _primary_term } = doc; const { type, namespace, namespaces, originId } = _source; @@ -67,12 +78,14 @@ export class SavedObjectsSerializer { _seq_no != null || _primary_term != null ? encodeVersion(_seq_no!, _primary_term!) : undefined; + const includeNamespace = namespace && (flexible || 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: this.trimIdPrefix(namespace, type, _id, flexible), + ...(includeNamespace && { namespace }), + ...(includeNamespaces && { namespaces }), ...(originId && { originId }), attributes: _source[type], references: _source.references || [], @@ -132,20 +145,30 @@ export class SavedObjectsSerializer { return `${namespacePrefix}${type}:${id || uuid.v1()}`; } - private trimIdPrefix(namespace: string | undefined, type: string, id: string) { + private trimIdPrefix(namespace: string | undefined, type: string, id: string, flexible: boolean) { assertNonEmptyString(id, 'document id'); assertNonEmptyString(type, 'saved object type'); - const { prefix, idMatchesPrefix } = this.parseIdPrefix(namespace, type, id); + const { prefix, idMatchesPrefix } = this.parseIdPrefix(namespace, type, id, flexible); return idMatchesPrefix ? id.slice(prefix.length) : id; } - private parseIdPrefix(namespace: string | undefined, type: string, id: string) { + private parseIdPrefix( + namespace: string | undefined, + type: string, + id: string, + flexible: boolean + ) { const namespacePrefix = namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; - const prefix = `${namespacePrefix}${type}:`; + let prefix = `${namespacePrefix}${type}:`; - const idMatchesPrefix = id.startsWith(prefix); + let idMatchesPrefix = id.startsWith(prefix); + if (!idMatchesPrefix && namespace && flexible && this.registry.isMultiNamespace(type)) { + // retry checking the prefix by treating this raw ID as the single-namespace ID format + prefix = `${namespace}:${type}:`; + idMatchesPrefix = id.startsWith(prefix); + } return { prefix, idMatchesPrefix }; } diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 8b3eebceb2c5a..aa3ee2fa07694 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -80,3 +80,17 @@ export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial * @public */ export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable; + +/** + * Options that can be specified when using the saved objects serializer to parse a raw document. + * + * @public + */ +export interface SavedObjectsRawDocParseOptions { + /** + * Optional flag to allow for flexible 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. + */ + flexible?: boolean; +} diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index b16eeb2aa03a6..2387e39ba2e64 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -248,6 +248,41 @@ export interface SavedObjectsType { * An optional map of {@link SavedObjectMigrationFn | migrations} to be used to migrate the type. */ migrations?: 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. */ From 394188bdbde5272a5ac98643ac7e98a6ceb631d7 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 20 Nov 2020 13:10:15 -0500 Subject: [PATCH 06/22] Add "reference" transforms for object types These are applied to all types using the `convertToMultiNamespaceTypeVersion` field when defining any object type. Any time the Kibana version is changed, all reference transforms for applicable versions are applied, and all documents' `referencesMigrationVersion` fields are updated to match the Kibana version. --- .../build_active_mappings.test.ts.snap | 8 + .../migrations/core/build_active_mappings.ts | 3 + .../migrations/core/document_migrator.test.ts | 252 +++++++++++++++++- .../migrations/core/document_migrator.ts | 173 ++++++++---- .../migrations/core/elastic_index.test.ts | 21 +- .../migrations/core/elastic_index.ts | 32 ++- .../migrations/core/index_migrator.test.ts | 7 + .../migrations/core/index_migrator.ts | 5 +- .../migrations/core/migration_context.ts | 3 + .../kibana_migrator.test.ts.snap | 4 + .../migrations/kibana/kibana_migrator.ts | 3 + .../serialization/serializer.test.ts | 41 +++ .../saved_objects/serialization/serializer.ts | 17 +- .../saved_objects/serialization/types.ts | 1 + .../service/lib/repository.test.js | 2 + .../saved_objects/service/lib/repository.ts | 2 +- .../apis/saved_objects/migrations.ts | 25 +- 17 files changed, 524 insertions(+), 75 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index f8ef47cae8944..32c5f326f0265 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -11,6 +11,7 @@ Object { "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", + "referencesMigrationVersion": "2f4316de49999235636386fe51dc06c1", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", }, @@ -50,6 +51,9 @@ Object { }, "type": "nested", }, + "referencesMigrationVersion": Object { + "type": "keyword", + }, "type": Object { "type": "keyword", }, @@ -70,6 +74,7 @@ Object { "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", + "referencesMigrationVersion": "2f4316de49999235636386fe51dc06c1", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", "type": "2f4316de49999235636386fe51dc06c1", @@ -113,6 +118,9 @@ Object { }, "type": "nested", }, + "referencesMigrationVersion": Object { + "type": "keyword", + }, "secondType": Object { "dynamic": false, "properties": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 2f4427b27b6bf..511eac17ed358 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -164,6 +164,9 @@ function defaultMapping(): IndexMapping { }, }, }, + referencesMigrationVersion: { + type: 'keyword', + }, }, }; } diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index a0934be206a30..3763c3a78f4ee 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -28,6 +28,7 @@ import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; const mockLoggerFactory = loggingSystemMock.create(); const mockLogger = mockLoggerFactory.get('mock logger'); +const kibanaVersion = '25.2.3'; const createRegistry = (...types: Array>) => { const registry = new SavedObjectTypeRegistry(); @@ -51,7 +52,7 @@ beforeEach(() => { describe('DocumentMigrator', () => { function testOpts() { return { - kibanaVersion: '25.2.3', + kibanaVersion, typeRegistry: createRegistry(), log: mockLogger, }; @@ -202,6 +203,7 @@ describe('DocumentMigrator', () => { type: 'user', attributes: { name: 'Chris' }, migrationVersion: { user: '1.2.3' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -252,6 +254,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Tyler' }, migrationVersion: { acl: '2.3.5' }, acl: 'admins-only, sucka!', + referencesMigrationVersion: kibanaVersion, }); }); @@ -289,10 +292,11 @@ describe('DocumentMigrator', () => { id: 'me', type: 'user', attributes: { name: 'Tyler' }, + referencesMigrationVersion: kibanaVersion, }); }); - it('assumes documents w/ undefined migrationVersion are up to date', () => { + it('assumes documents w/ undefined migrationVersion and correct referencesMigrationVersion are up to date', () => { const migrator = new DocumentMigrator({ ...testOpts(), typeRegistry: createRegistry( @@ -321,6 +325,7 @@ describe('DocumentMigrator', () => { type: 'user', attributes: { name: 'Tyler' }, bbb: 'Shazm', + referencesMigrationVersion: kibanaVersion, } as SavedObjectUnsanitizedDoc); expect(actual).toEqual({ id: 'me', @@ -331,6 +336,7 @@ describe('DocumentMigrator', () => { user: '1.0.0', bbb: '2.3.4', }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -357,6 +363,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Callie', b: 'B', c: 'C' }, migrationVersion: { dog: '2.0.1' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -423,6 +430,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Callie', a: 1, b: 2, c: 3 }, migrationVersion: { dog: '10.0.1' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -456,6 +464,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Callie' }, animal: 'Animal: Doggie', migrationVersion: { animal: '1.0.0', dog: '2.2.4' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -482,6 +491,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { title: 'Title: Name: Callie' }, migrationVersion: { dog: '1.0.2' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -514,6 +524,7 @@ describe('DocumentMigrator', () => { type: 'cat', attributes: { name: 'Kitty Callie' }, migrationVersion: { dog: '2.2.4', cat: '1.0.0' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -590,6 +601,7 @@ describe('DocumentMigrator', () => { type: 'cat', attributes: { name: 'Shiny' }, migrationVersion: { cat: '3.0.0' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -614,6 +626,7 @@ describe('DocumentMigrator', () => { type: 'cat', attributes: { name: 'Boo' }, migrationVersion: { cat: '1.0.0', foo: '5.6.7' }, + referencesMigrationVersion: kibanaVersion, }); }); @@ -693,6 +706,14 @@ describe('DocumentMigrator', () => { '3.2.3': (doc: SavedObjectUnsanitizedDoc) => doc, '2.0.0': (doc: SavedObjectUnsanitizedDoc) => doc, }, + }, + { + name: 'ccc', + namespaceType: 'multiple', + migrations: { + '9.0.0': (doc: SavedObjectUnsanitizedDoc) => doc, + }, + convertToMultiNamespaceTypeVersion: '11.0.0', // this results in reference transforms getting added to other types, but does not increase the migrationVersion of those types } ), }); @@ -700,11 +721,12 @@ describe('DocumentMigrator', () => { expect(migrationVersion).toEqual({ aaa: '10.4.0', bbb: '3.2.3', + ccc: '11.0.0', }); }); describe('conversion to multi-namespace type', () => { - it('assumes documents w/ undefined migrationVersion are up to date', () => { + it('assumes documents w/ undefined migrationVersion and correct referencesMigrationVersion are up to date', () => { const migrator = new DocumentMigrator({ ...testOpts(), typeRegistry: createRegistry( @@ -716,6 +738,7 @@ describe('DocumentMigrator', () => { id: 'mischievous', type: 'dog', attributes: { name: 'Ann' }, + referencesMigrationVersion: kibanaVersion, } as SavedObjectUnsanitizedDoc; const actual = migrator.migrateAndConvert(obj); expect(actual).toEqual({ @@ -723,32 +746,81 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Ann' }, migrationVersion: { dog: '1.0.0' }, + referencesMigrationVersion: kibanaVersion, // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario }); }); - it('skips conversion transforms when using `migrate`', () => { + it('skips reference transforms and conversion transforms when using `migrate`', () => { const migrator = new DocumentMigrator({ ...testOpts(), - typeRegistry: createRegistry({ - name: 'dog', - namespaceType: 'multiple', - convertToMultiNamespaceTypeVersion: '1.0.0', - }), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), }); const obj = { id: 'cowardly', type: 'dog', attributes: { name: 'Leslie' }, migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + namespace: 'foo-namespace', }; const actual = migrator.migrate(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); expect(actual).toEqual({ id: 'cowardly', type: 'dog', attributes: { name: 'Leslie' }, migrationVersion: { dog: '1.0.0' }, - // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + referencesMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + // there is no 'namespaces' field because no conversion transform was applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario + }); + }); + + describe('correctly applies reference transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'single' }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + const obj = { + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual({ + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + }); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:toy:favorite', 'DNSUUID'); + expect(actual).toEqual({ + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + }); }); }); @@ -776,6 +848,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Wally' }, migrationVersion: { dog: '1.0.0' }, + referencesMigrationVersion: kibanaVersion, namespaces: ['default'], }); }); @@ -789,12 +862,113 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Wally' }, migrationVersion: { dog: '1.0.0' }, + referencesMigrationVersion: kibanaVersion, namespaces: ['foo-namespace'], originId: 'loud', }); }); }); + describe('correctly applies reference and conversion transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { name: 'dog', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + const obj = { + id: 'cute', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual({ + id: 'cute', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: { dog: '1.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + namespaces: ['default'], + }); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(2); + expect(mockUuidv5).toHaveBeenNthCalledWith(1, 'foo-namespace:toy:favorite', 'DNSUUID'); + expect(mockUuidv5).toHaveBeenNthCalledWith(2, 'foo-namespace:dog:cute', 'DNSUUID'); + expect(actual).toEqual({ + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: { dog: '1.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'cute', + }); + }); + }); + + describe('correctly applies reference and migration transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'dog', + namespaceType: 'single', + migrations: { + '1.0.0': setAttr('migrationVersion.dog', '2.0.0'), + '2.0.0': (doc) => doc, // noop + }, + }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + const obj = { + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual({ + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + }); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(1); + expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:toy:favorite', 'DNSUUID'); + expect(actual).toEqual({ + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + }); + }); + }); + describe('correctly applies conversion and migration transforms', () => { const migrator = new DocumentMigrator({ ...testOpts(), @@ -823,6 +997,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Remy' }, migrationVersion: { dog: '2.0.0' }, + referencesMigrationVersion: kibanaVersion, namespaces: ['default'], }); }); @@ -836,11 +1011,68 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Remy' }, migrationVersion: { dog: '2.0.0' }, + referencesMigrationVersion: kibanaVersion, namespaces: ['foo-namespace'], originId: 'hungry', }); }); }); + + describe('correctly applies reference, conversion, and migration transforms', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + typeRegistry: createRegistry( + { + name: 'dog', + namespaceType: 'multiple', + migrations: { + '1.0.0': setAttr('migrationVersion.dog', '2.0.0'), + '2.0.0': (doc) => doc, // noop + }, + convertToMultiNamespaceTypeVersion: '1.0.0', + }, + { name: 'toy', namespaceType: 'multiple', convertToMultiNamespaceTypeVersion: '1.0.0' } + ), + }); + const obj = { + id: 'pretty', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: {}, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], + }; + + it('in the default space', () => { + const actual = migrator.migrateAndConvert(obj); + expect(mockUuidv5).not.toHaveBeenCalled(); + expect(actual).toEqual({ + id: 'pretty', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + namespaces: ['default'], + }); + }); + + it('in a non-default space', () => { + const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); + expect(mockUuidv5).toHaveBeenCalledTimes(2); + expect(mockUuidv5).toHaveBeenNthCalledWith(1, 'foo-namespace:toy:favorite', 'DNSUUID'); + expect(mockUuidv5).toHaveBeenNthCalledWith(2, 'foo-namespace:dog:pretty', 'DNSUUID'); + expect(actual).toEqual({ + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'pretty', + }); + }); + }); }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 41e401b23fd08..f3ee38a6372f3 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -93,7 +93,8 @@ interface DocumentMigratorOptions { interface ActiveMigrations { [type: string]: { - latestVersion: string; + latestVersion?: string; + latestReferenceVersion?: string; transforms: Transform[]; }; } @@ -101,7 +102,7 @@ interface ActiveMigrations { interface Transform { version: string; transform: (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; - transformType: 'migrate' | 'convert'; + transformType: 'migrate' | 'convert' | 'reference'; } /** @@ -147,7 +148,13 @@ export class DocumentMigrator implements VersionedTransformer { * @memberof DocumentMigrator */ public get migrationVersion(): SavedObjectsMigrationVersion { - return _.mapValues(this.migrations, ({ latestVersion }) => latestVersion); + return Object.entries(this.migrations).reduce((acc, [prop, { latestVersion }]) => { + // some migration objects won't have a latestVersion (they only contain reference transforms that are applied from other types) + if (latestVersion) { + return { ...acc, [prop]: latestVersion }; + } + return acc; + }, {}); } /** @@ -269,39 +276,43 @@ function buildActiveMigrations( typeRegistry: ISavedObjectTypeRegistry, log: Logger ): ActiveMigrations { - return typeRegistry - .getAllTypes() - .filter( - (type) => - (type.migrations && Object.keys(type.migrations).length > 0) || - type.convertToMultiNamespaceTypeVersion - ) - .reduce((migrations, type) => { - const migrationTransforms = Object.entries(type.migrations!).map( - ([version, transform]) => ({ - version, - transform: wrapWithTry(version, type.name, transform, log), - transformType: 'migrate', - }) - ); - const conversionTransforms = getConversionTransforms(type); - const transforms = [...migrationTransforms, ...conversionTransforms].sort( - transformComparator - ); - return { - ...migrations, - [type.name]: { - latestVersion: _.last(transforms)!.version, - transforms, - }, - }; - }, {} as ActiveMigrations); + const referenceTransforms = getReferenceTransforms(typeRegistry); + + return typeRegistry.getAllTypes().reduce((migrations, type) => { + const migrationTransforms = Object.entries(type.migrations ?? {}).map( + ([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; + } + return { + ...migrations, + [type.name]: { + latestVersion: _.last(transforms.filter((x) => x.transformType !== 'reference'))?.version, + latestReferenceVersion: _.last(transforms.filter((x) => x.transformType === 'reference')) + ?.version, + transforms, + }, + }; + }, {} as ActiveMigrations); } /** * Creates a function which migrates and validates any document that is passed to it. */ function buildDocumentTransform({ + kibanaVersion, migrations, }: { kibanaVersion: string; @@ -313,8 +324,8 @@ function buildDocumentTransform({ ) { const { convertTypes = false } = options; const result = doc.migrationVersion - ? applyMigrations(doc, migrations, convertTypes) - : markAsUpToDate(doc, migrations); + ? applyMigrations(doc, migrations, kibanaVersion, convertTypes) + : 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 @@ -330,12 +341,15 @@ function buildDocumentTransform({ function applyMigrations( doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations, + kibanaVersion: string, convertTypes: boolean ) { while (true) { const prop = nextUnmigratedProp(doc, migrations); if (!prop) { - return doc; + // regardless of whether or not any reference transform was applied, update the referencesMigrationVersion + // this is needed to ensure that newly created documents have an up-to-date referencesMigrationVersion field + return { ...doc, referencesMigrationVersion: kibanaVersion }; } doc = migrateProp(doc, prop, migrations, convertTypes); } @@ -361,13 +375,18 @@ 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; }, {}), + referencesMigrationVersion: kibanaVersion, }; } @@ -410,9 +429,43 @@ function getConversionTransforms(type: SavedObjectsType): Transform[] { } /** - * Transforms are sorted in ascending order by version. One version may contain multiple transforms; 'convert' transforms always run first, - * and 'migrate' transforms always run last. This is because '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. + * 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: key, name }) => { + const val = acc.get(key!) ?? new Set(); + return acc.set(key!, val.add(name)); + }, new Map>()); + + return Array.from(transformMap, ([key, val]) => ({ + version: key, + transform: (doc) => { + const { namespace, references } = doc; + if (namespace && references?.length) { + return { + ...doc, + references: references.map(({ type, id, ...attrs }) => ({ + ...attrs, + type, + id: val.has(type) ? uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS) : id, + })), + }; + } + return doc; + }, + 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); @@ -423,6 +476,10 @@ function transformComparator(a: Transform, b: Transform) { 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; @@ -461,6 +518,23 @@ function wrapWithTry( }; } +function getHasPendingReferenceTransform( + doc: SavedObjectUnsanitizedDoc, + migrations: ActiveMigrations, + prop: string +) { + if (!migrations[prop]) { + return false; + } + + const { latestReferenceVersion } = migrations[prop]; + const { referencesMigrationVersion } = doc; + return ( + latestReferenceVersion && + (!referencesMigrationVersion || Semver.gt(latestReferenceVersion, referencesMigrationVersion)) + ); +} + /** * Finds the first unmigrated property in the specified document. */ @@ -468,10 +542,7 @@ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMi return props(doc).find((p) => { const latestVersion = propVersion(migrations, p); const docVersion = propVersion(doc, p); - - if (latestVersion === docVersion) { - return false; - } + const hasPendingReferenceTransform = getHasPendingReferenceTransform(doc, migrations, p); // 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 @@ -486,7 +557,7 @@ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMi ); } - return true; + return (latestVersion && latestVersion !== docVersion) || hasPendingReferenceTransform; }); } @@ -510,11 +581,16 @@ function migrateProp( } if (transformType === 'migrate' || convertTypes) { - // migrate transforms are always applied, but conversion transforms are only applied when Kibana is upgraded + // migrate transforms are always applied, but conversion transforms and reference transforms are only applied when Kibana is upgraded doc = transform(doc); } - migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version); - doc.migrationVersion = _.clone(migrationVersion); + if (transformType === 'reference') { + // regardless of whether or not the reference transform was applied, increment the version + doc.referencesMigrationVersion = 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 @@ -534,9 +610,14 @@ function applicableTransforms( prop: string ) { const minVersion = propVersion(doc, prop); + const minReferenceVersion = doc.referencesMigrationVersion || '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; } diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index 0b3ad1b6e3cc8..dbbbec347b69c 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -568,6 +568,7 @@ describe('ElasticIndex', () => { mappings, count, migrations, + kibanaVersion, }: any) { client.indices.get = jest.fn().mockReturnValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -581,7 +582,12 @@ describe('ElasticIndex', () => { }) ); - const hasMigrations = await Index.migrationsUpToDate(client, index, migrations); + const hasMigrations = await Index.migrationsUpToDate( + client, + index, + migrations, + kibanaVersion + ); return { hasMigrations }; } @@ -595,6 +601,7 @@ describe('ElasticIndex', () => { }, count: 0, migrations: { dashy: '2.3.4' }, + kibanaVersion: '7.10.0', }); expect(hasMigrations).toBeFalsy(); @@ -622,6 +629,7 @@ describe('ElasticIndex', () => { }, count: 2, migrations: {}, + kibanaVersion: '7.10.0', }); expect(hasMigrations).toBeTruthy(); @@ -663,6 +671,7 @@ describe('ElasticIndex', () => { }, count: 3, migrations: { dashy: '23.2.5' }, + kibanaVersion: '7.10.0', }); expect(hasMigrations).toBeFalsy(); @@ -688,6 +697,7 @@ describe('ElasticIndex', () => { bashy: '99.9.3', flashy: '3.4.5', }, + kibanaVersion: '7.10.0', }); function shouldClause(type: string, version: string) { @@ -713,6 +723,15 @@ describe('ElasticIndex', () => { shouldClause('dashy', '23.2.5'), shouldClause('bashy', '99.9.3'), shouldClause('flashy', '3.4.5'), + { + bool: { + must_not: { + term: { + referencesMigrationVersion: '7.10.0', + }, + }, + }, + }, ], }, }, diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index d5093bfd8dc42..ead2a635a5a0e 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -158,6 +158,7 @@ export async function migrationsUpToDate( client: MigrationEsClient, index: string, migrationVersion: SavedObjectsMigrationVersion, + kibanaVersion: string, retryCount: number = 10 ): Promise { try { @@ -176,18 +177,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: { + referencesMigrationVersion: kibanaVersion, }, }, - ], + }, }, - })), + ], }, }, }, @@ -205,7 +217,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); } } diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 5654efafcaa09..9af1b9c6796e9 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -35,6 +35,7 @@ describe('IndexMigrator', () => { batchSize: 10, client: elasticsearchClientMock.createElasticsearchClient(), index: '.kibana', + kibanaVersion: '7.10.0', log: loggingSystemMock.create().get(), mappingProperties: {}, pollInterval: 1, @@ -69,6 +70,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', + referencesMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -89,6 +91,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, + referencesMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -190,6 +193,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', + referencesMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -211,6 +215,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, + referencesMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -251,6 +256,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', + referencesMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -272,6 +278,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, + referencesMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 98409bb7ad48c..4b9c74a940148 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -70,13 +70,14 @@ export class IndexMigrator { * Determines what action the migration system needs to take (none, patch, migrate). */ async function requiresMigration(context: Context): Promise { - 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) { diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index 0ea362d65623e..6ee8dfeecf91f 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -43,6 +43,7 @@ export interface MigrationOpts { scrollDuration: string; client: MigrationEsClient; index: string; + kibanaVersion: string; log: Logger; mappingProperties: SavedObjectsTypeMappingDefinitions; documentMigrator: VersionedTransformer; @@ -65,6 +66,7 @@ export interface Context { source: Index.FullIndexInfo; dest: Index.FullIndexInfo; documentMigrator: VersionedTransformer; + kibanaVersion: string; log: SavedObjectsMigrationLogger; batchSize: number; pollInterval: number; @@ -89,6 +91,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { alias, source, dest, + kibanaVersion: opts.kibanaVersion, log: new MigrationLogger(log), batchSize: opts.batchSize, documentMigrator: opts.documentMigrator, diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 9311292a6a0ed..29e2c4970f982 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -11,6 +11,7 @@ Object { "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", + "referencesMigrationVersion": "2f4316de49999235636386fe51dc06c1", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", }, @@ -58,6 +59,9 @@ Object { }, "type": "nested", }, + "referencesMigrationVersion": Object { + "type": "keyword", + }, "type": Object { "type": "keyword", }, diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 18a385c6994b8..50ade55e692e1 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -60,6 +60,7 @@ export class KibanaMigrator { private readonly savedObjectsConfig: SavedObjectsMigrationConfigType; private readonly documentMigrator: VersionedTransformer; private readonly kibanaConfig: KibanaConfigType; + private readonly kibanaVersion: string; private readonly log: Logger; private readonly mappingProperties: SavedObjectsTypeMappingDefinitions; private readonly typeRegistry: ISavedObjectTypeRegistry; @@ -83,6 +84,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); @@ -156,6 +158,7 @@ export class KibanaMigrator { client: this.client, documentMigrator: this.documentMigrator, index, + kibanaVersion: this.kibanaVersion, log: this.log, mappingProperties: indexMap[index].typeMappings, pollInterval: this.savedObjectsConfig.pollInterval, diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 132c4102fb860..7cc2aec720abb 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -142,6 +142,27 @@ describe('#rawToSavedObject', () => { expect(expected).toEqual(actual); }); + test('if specified it copies the _source.referencesMigrationVersion property to referencesMigrationVersion', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + referencesMigrationVersion: '1.2.3', + }, + }); + expect(actual).toHaveProperty('referencesMigrationVersion', '1.2.3'); + }); + + test(`if _source.referencesMigrationVersion is unspecified it doesn't set referencesMigrationVersion`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('referencesMigrationVersion'); + }); + test(`if version is unspecified it doesn't set version`, () => { const actual = singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', @@ -299,6 +320,7 @@ describe('#rawToSavedObject', () => { foo: '1.2.3', bar: '9.8.7', }, + referencesMigrationVersion: '4.5.6', namespace: 'foo-namespace', updated_at: String(new Date()), references: [], @@ -546,6 +568,25 @@ describe('#savedObjectToRaw', () => { expect(actual._source).not.toHaveProperty('migrationVersion'); }); + test('it copies the referencesMigrationVersion property to _source.referencesMigrationVersion', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + referencesMigrationVersion: '1.2.3', + } as any); + + expect(actual._source).toHaveProperty('referencesMigrationVersion', '1.2.3'); + }); + + test(`if unspecified it doesn't add referencesMigrationVersion property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('referencesMigrationVersion'); + }); + test('it decodes the version property to _seq_no and _primary_term', () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index e88b48d1fd6bd..51636eb289625 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -72,7 +72,15 @@ export class SavedObjectsSerializer { ): SavedObjectSanitizedDoc { const { flexible = false } = options; const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId } = _source; + const { + type, + namespace, + namespaces, + originId, + migrationVersion, + references, + referencesMigrationVersion, + } = _source; const version = _seq_no != null || _primary_term != null @@ -88,8 +96,9 @@ export class SavedObjectsSerializer { ...(includeNamespaces && { namespaces }), ...(originId && { originId }), attributes: _source[type], - references: _source.references || [], - ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), + references: references || [], + ...(migrationVersion && { migrationVersion }), + ...(referencesMigrationVersion && { referencesMigrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), ...(version && { version }), }; @@ -113,6 +122,7 @@ export class SavedObjectsSerializer { updated_at, version, references, + referencesMigrationVersion, } = savedObj; const source = { [type]: attributes, @@ -122,6 +132,7 @@ export class SavedObjectsSerializer { ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), + ...(referencesMigrationVersion && { referencesMigrationVersion }), ...(updated_at && { updated_at }), }; diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index aa3ee2fa07694..2c725cfcb1da0 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -55,6 +55,7 @@ interface SavedObjectDoc { namespace?: string; namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; + referencesMigrationVersion?: string; version?: string; updated_at?: string; originId?: string; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 8443d1dd07184..bf231da868bac 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -964,6 +964,7 @@ describe('SavedObjectsRepository', () => { ...response.items[0].create, _source: { ...response.items[0].create._source, + referencesMigrationVersion: '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}$/), @@ -972,6 +973,7 @@ describe('SavedObjectsRepository', () => { ...response.items[1].create, _source: { ...response.items[1].create._source, + referencesMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation namespaces: response.items[1].create._source.namespaces, }, }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 2f09ad71de558..1c2e64e103f1a 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1683,7 +1683,7 @@ export class SavedObjectsRepository { if (this._registry.isSingleNamespace(type)) { savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)]; } - return omit(savedObject, 'namespace') as SavedObject; + return omit(savedObject, ['namespace', 'referencesMigrationVersion']) as SavedObject; } /** diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index fa9c2fd1a2d7f..fb0b062b1dc4c 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -39,6 +39,7 @@ import { } from '../../../../src/core/server/saved_objects'; import { FtrProviderContext } from '../../ftr_provider_context'; +const KIBANA_VERSION = '99.9.9'; function getLogMock() { return { debug() {}, @@ -156,6 +157,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'bar:o', @@ -163,14 +165,22 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + id: 'baz:u', + type: 'baz', + baz: { title: 'Terrific!' }, + references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, - { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' }, references: [] }, { id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:e', @@ -178,6 +188,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); }); @@ -227,6 +238,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'bar:o', @@ -234,6 +246,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:a', @@ -241,6 +254,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:e', @@ -248,6 +262,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); @@ -259,6 +274,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 68, name: 'NAME i' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'bar:o', @@ -266,6 +282,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 6, name: 'NAME o' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:a', @@ -273,6 +290,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:e', @@ -280,6 +298,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); }); @@ -338,6 +357,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' }, references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); }); @@ -395,7 +415,7 @@ async function migrateIndex({ types.forEach((type) => typeRegistry.registerType(type)); const documentMigrator = new DocumentMigrator({ - kibanaVersion: '99.9.9', + kibanaVersion: KIBANA_VERSION, typeRegistry, log: getLogMock(), }); @@ -404,6 +424,7 @@ async function migrateIndex({ client: createMigrationEsClient(esClient, getLogMock()), documentMigrator, index, + kibanaVersion: KIBANA_VERSION, obsoleteIndexTemplatePattern, mappingProperties, batchSize: 10, From 7d3e99d3a9a4a0cca42f190ba0cd6f4ba3a20706 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 30 Nov 2020 15:50:14 -0500 Subject: [PATCH 07/22] Add LegacyUrlAlias and SavedObjectsClient.resolve() New integration tests (except for Jest integration tests) are intentionally excluded. Will be added in a separate commit after conversion transforms include aliases. --- src/core/server/index.ts | 1 + .../saved_objects/object_types/constants.ts | 23 ++ .../saved_objects/object_types/index.ts | 22 ++ .../object_types/registration.test.ts | 36 +++ .../object_types/registration.ts | 46 ++++ .../saved_objects/object_types/types.ts | 30 +++ src/core/server/saved_objects/routes/index.ts | 2 + .../routes/integration_tests/resolve.test.ts | 95 ++++++++ .../server/saved_objects/routes/resolve.ts | 40 +++ .../saved_objects_service.test.ts | 2 + .../saved_objects/saved_objects_service.ts | 3 + .../serialization/serializer.test.ts | 22 ++ .../saved_objects/serialization/serializer.ts | 12 + .../service/lib/repository.mock.ts | 1 + .../service/lib/repository.test.js | 227 ++++++++++++++++++ .../saved_objects/service/lib/repository.ts | 188 ++++++++++++--- .../service/saved_objects_client.mock.ts | 1 + .../service/saved_objects_client.test.js | 16 ++ .../service/saved_objects_client.ts | 24 ++ ...ypted_saved_objects_client_wrapper.test.ts | 134 +++++++++++ .../encrypted_saved_objects_client_wrapper.ts | 13 + .../server/audit/audit_events.test.ts | 18 ++ .../security/server/audit/audit_events.ts | 3 + ...ecure_saved_objects_client_wrapper.test.ts | 77 ++++++ .../secure_saved_objects_client_wrapper.ts | 36 +++ .../spaces_saved_objects_client.test.ts | 31 +++ .../spaces_saved_objects_client.ts | 18 ++ 27 files changed, 1085 insertions(+), 36 deletions(-) create mode 100644 src/core/server/saved_objects/object_types/constants.ts create mode 100644 src/core/server/saved_objects/object_types/index.ts create mode 100644 src/core/server/saved_objects/object_types/registration.test.ts create mode 100644 src/core/server/saved_objects/object_types/registration.ts create mode 100644 src/core/server/saved_objects/object_types/types.ts create mode 100644 src/core/server/saved_objects/routes/integration_tests/resolve.test.ts create mode 100644 src/core/server/saved_objects/routes/resolve.ts diff --git a/src/core/server/index.ts b/src/core/server/index.ts index c098c08d87d84..3d9188829eca3 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -279,6 +279,7 @@ export { SavedObjectUnsanitizedDoc, SavedObjectsRepositoryFactory, SavedObjectsResolveImportErrorsOptions, + SavedObjectsResolveResponse, SavedObjectsSerializer, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, diff --git a/src/core/server/saved_objects/object_types/constants.ts b/src/core/server/saved_objects/object_types/constants.ts new file mode 100644 index 0000000000000..246b38ee8e10a --- /dev/null +++ b/src/core/server/saved_objects/object_types/constants.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @internal + */ +export const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias'; diff --git a/src/core/server/saved_objects/object_types/index.ts b/src/core/server/saved_objects/object_types/index.ts new file mode 100644 index 0000000000000..d9a3e6252f4ef --- /dev/null +++ b/src/core/server/saved_objects/object_types/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { LEGACY_URL_ALIAS_TYPE } from './constants'; +export { LegacyUrlAlias } from './types'; +export { registerCoreObjectTypes } from './registration'; diff --git a/src/core/server/saved_objects/object_types/registration.test.ts b/src/core/server/saved_objects/object_types/registration.test.ts new file mode 100644 index 0000000000000..6a5daf1a357d5 --- /dev/null +++ b/src/core/server/saved_objects/object_types/registration.test.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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 }) + ); + }); + }); +}); diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts new file mode 100644 index 0000000000000..5bb9981d88e65 --- /dev/null +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LEGACY_URL_ALIAS_TYPE } from './constants'; +import { ISavedObjectTypeRegistry, SavedObjectTypeRegistry } from '..'; + +const legacyUrlAliasMappings = { + properties: { + targetNamespace: { type: 'keyword' }, + targetType: { type: 'keyword' }, + targetId: { type: 'keyword' }, + lastResolved: { type: 'date' }, + resolveCounter: { type: 'integer' }, + disabled: { type: 'boolean' }, + }, +}; + +/** + * @internal + */ +export function registerCoreObjectTypes( + typeRegistry: ISavedObjectTypeRegistry & Pick +) { + typeRegistry.registerType({ + name: LEGACY_URL_ALIAS_TYPE, + namespaceType: 'agnostic', + mappings: legacyUrlAliasMappings, + hidden: true, + }); +} diff --git a/src/core/server/saved_objects/object_types/types.ts b/src/core/server/saved_objects/object_types/types.ts new file mode 100644 index 0000000000000..8ba54168cac78 --- /dev/null +++ b/src/core/server/saved_objects/object_types/types.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @internal + */ +export interface LegacyUrlAlias { + targetNamespace: string; + targetType: string; + targetId: string; + lastResolved?: string; + resolveCounter?: number; + disabled?: boolean; +} diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index fd57a9f3059e3..0e42414fb19e3 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -22,6 +22,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'; @@ -49,6 +50,7 @@ export function registerRoutes({ const router = http.createRouter('/api/saved_objects/'); registerGetRoute(router); + registerResolveRoute(router); registerCreateRoute(router); registerDeleteRoute(router); registerFindRoute(router); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts new file mode 100644 index 0000000000000..8ab5ea138c967 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts @@ -0,0 +1,95 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import supertest from 'supertest'; +import { registerResolveRoute } from '../resolve'; +import { ContextService } from '../../../context'; +import { savedObjectsClientMock } from '../../service/saved_objects_client.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; + let savedObjectsClient: ReturnType; + + 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/'); + registerResolveRoute(router); + + 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-*']); + }); +}); diff --git a/src/core/server/saved_objects/routes/resolve.ts b/src/core/server/saved_objects/routes/resolve.ts new file mode 100644 index 0000000000000..2967273457dbc --- /dev/null +++ b/src/core/server/saved_objects/routes/resolve.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerResolveRoute = (router: IRouter) => { + 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 result = await context.core.savedObjects.client.resolve(type, id); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 8e4c73137033d..a3657af3d3ecc 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -40,6 +40,7 @@ import { NodesVersionCompatibility } from '../elasticsearch/version_check/ensure import { SavedObjectsRepository } from './service/lib/repository'; jest.mock('./service/lib/repository'); +jest.mock('./object_types'); describe('SavedObjectsService', () => { const createCoreContext = ({ @@ -142,6 +143,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()); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 5cc59d55a254e..70e91a1e8ec49 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -53,6 +53,7 @@ import { registerRoutes } from './routes'; import { ServiceStatus } from '../status'; import { calculateStatus$ } from './status'; import { createMigrationEsClient } from './migrations/core/'; +import { registerCoreObjectTypes } from './object_types'; /** * Saved Objects is Kibana's data persistence mechanism allowing plugins to * use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods @@ -306,6 +307,8 @@ export class SavedObjectsService migratorPromise: this.migrator$.pipe(first()).toPromise(), }); + registerCoreObjectTypes(this.typeRegistry); + return { status$: calculateStatus$( this.migrator$.pipe(switchMap((migrator) => migrator.getStatus$())), diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 7cc2aec720abb..60e983efb603a 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -22,6 +22,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); @@ -1282,3 +1283,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); + }); + }); +}); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 51636eb289625..179733e6f4265 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -18,6 +18,7 @@ */ import uuid from 'uuid'; +import { LEGACY_URL_ALIAS_TYPE } from '../object_types'; import { decodeVersion, encodeVersion } from '../version'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { @@ -156,6 +157,17 @@ export class SavedObjectsSerializer { return `${namespacePrefix}${type}:${id || uuid.v1()}`; } + /** + * 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}`; + } + private trimIdPrefix(namespace: string | undefined, type: string, id: string, flexible: boolean) { assertNonEmptyString(id, 'document id'); assertNonEmptyString(type, 'saved object type'); diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index 1b38a300debe6..0e8f345e5642c 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -28,6 +28,7 @@ const create = (): jest.Mocked => ({ bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index bf231da868bac..b99b0ef1266f4 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -24,6 +24,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 { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { esKuery } from '../../es_query'; @@ -226,6 +227,7 @@ describe('SavedObjectsRepository', () => { rawToSavedObject: jest.fn(), savedObjectToRaw: jest.fn(), generateRawId: jest.fn(), + generateRawLegacyUrlAliasId: jest.fn(), trimIdPrefix: jest.fn(), }; const _serializer = new SavedObjectsSerializer(registry); @@ -3271,6 +3273,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'; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 1c2e64e103f1a..775e931444416 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -59,6 +59,7 @@ import { SavedObjectsDeleteFromNamespacesResponse, SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, + SavedObjectsResolveResponse, } from '../saved_objects_client'; import { SavedObject, @@ -67,6 +68,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; } - 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( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> { + 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); } /** @@ -1779,6 +1859,42 @@ export class SavedObjectsRepository { } return body as SavedObjectsRawDoc; } + + private getSavedObjectFromSource( + type: string, + id: string, + doc: { _seq_no: number; _primary_term: number; _source: SavedObjectsRawDocSource } + ): SavedObject { + 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, + }; + } + + private async resolveExactMatch( + type: string, + id: string, + options: SavedObjectsBaseOptions + ): Promise> { + const object = await this.get(type, id, options); + return { saved_object: object, outcome: 'exactMatch' }; + } } function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index 7b300129f0b9a..4609f60785a5d 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -31,6 +31,7 @@ const create = () => bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 3298121f9571f..65f0d6bcbdd1a 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -126,6 +126,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 = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 6cb9823c736e0..69bf948dc75c9 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -284,6 +284,15 @@ export interface SavedObjectsUpdateResponse references: SavedObjectReference[] | undefined; } +/** + * + * @public + */ +export interface SavedObjectsResolveResponse { + saved_object: SavedObject; + outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; +} + /** * * @public @@ -390,6 +399,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( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> { + return await this._repository.resolve(type, id, options); + } + /** * Updates an SavedObject * diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 3c722ccfabae2..51ffa6e8ec063 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -1500,6 +1500,140 @@ describe('#get', () => { }); }); +describe('#resolve', () => { + it('redirects request to underlying base client and does not alter response if type is not registered', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'unknown-type', + attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('unknown-type', 'some-id', options)).resolves.toEqual( + mockedResponse + ); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('unknown-type', 'some-id', options); + }); + + it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ + ...mockedResponse, + saved_object: { + ...mockedResponse.saved_object, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }, + }); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('includes both attributes and error with modified outcome if decryption fails.', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ + ...mockedResponse, + saved_object: { + ...mockedResponse.saved_object, + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }, + }); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.resolve.mockRejectedValue(failureReason); + + await expect(wrapper.resolve('known-type', 'some-id')).rejects.toThrowError(failureReason); + + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', undefined); + }); +}); + describe('#update', () => { it('redirects request to underlying base client if type is not registered', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index ddef9f477433c..da83024a66c6d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -213,6 +213,19 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ); } + public async resolve(type: string, id: string, options?: SavedObjectsBaseOptions) { + const resolveResult = await this.options.baseClient.resolve(type, id, options); + const object = await this.handleEncryptedAttributesInResponse( + resolveResult.saved_object, + undefined as unknown, + getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace) + ); + return { + ...resolveResult, + saved_object: object, + }; + } + public async update( type: string, id: string, diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts index c826bb1d33f99..1b6adb814b290 100644 --- a/x-pack/plugins/security/server/audit/audit_events.test.ts +++ b/x-pack/plugins/security/server/audit/audit_events.test.ts @@ -113,6 +113,12 @@ describe('#savedObjectEvent', () => { savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, + }) + ).not.toBeUndefined(); expect( savedObjectEvent({ action: SavedObjectAction.FIND, @@ -134,6 +140,18 @@ describe('#savedObjectEvent', () => { savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'config', id: 'SAVED_OBJECT_ID' }, + }) + ).toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, + }) + ).toBeUndefined(); expect( savedObjectEvent({ action: SavedObjectAction.FIND, diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 6aba78c936071..1973f1adf971f 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -170,6 +170,7 @@ export function userLoginEvent({ export enum SavedObjectAction { CREATE = 'saved_object_create', GET = 'saved_object_get', + RESOLVE = 'saved_object_resolve', UPDATE = 'saved_object_update', DELETE = 'saved_object_delete', FIND = 'saved_object_find', @@ -181,6 +182,7 @@ export enum SavedObjectAction { const eventVerbs = { saved_object_create: ['create', 'creating', 'created'], saved_object_get: ['access', 'accessing', 'accessed'], + saved_object_resolve: ['resolve', 'resolving', 'resolved'], saved_object_update: ['update', 'updating', 'updated'], saved_object_delete: ['delete', 'deleting', 'deleted'], saved_object_find: ['access', 'accessing', 'accessed'], @@ -196,6 +198,7 @@ const eventVerbs = { const eventTypes = { saved_object_create: EventType.CREATION, saved_object_get: EventType.ACCESS, + saved_object_resolve: EventType.ACCESS, saved_object_update: EventType.CHANGE, saved_object_delete: EventType.DELETION, saved_object_find: EventType.ACCESS, diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index c6f4ca6dd8afe..61fcdbacb289a 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -163,6 +163,7 @@ const expectObjectNamespaceFiltering = async ( // we don't know which base client method will be called; mock them all clientOpts.baseClient.create.mockReturnValue(returnValue as any); clientOpts.baseClient.get.mockReturnValue(returnValue as any); + // 'resolve' is excluded because it has a specific test case written for it clientOpts.baseClient.update.mockReturnValue(returnValue as any); clientOpts.baseClient.addToNamespaces.mockReturnValue(returnValue as any); clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(returnValue as any); @@ -969,6 +970,82 @@ describe('#get', () => { }); }); +describe('#resolve', () => { + const type = 'foo'; + const id = `${type}-id`; + const namespace = 'some-ns'; + const resolvedId = 'another-id'; // success audit records include the resolved ID, not the requested ID + const mockResult = { saved_object: { id: resolvedId } }; // mock result needs to have ID for audit logging + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.resolve, { type, id }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; + await expectForbiddenError(client.resolve, { type, id, options }, 'resolve'); + }); + + test(`returns result of baseClient.resolve when authorized`, async () => { + const apiCallReturnValue = mockResult; + clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + const result = await expectSuccess(client.resolve, { type, id, options }, 'resolve'); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const options = { namespace }; + await expectPrivilegeCheck(client.resolve, { type, id, options }, namespace); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; + + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + const namespaces = ['some-other-namespace', '*', namespace]; + const returnValue = { saved_object: { namespaces, id: resolvedId, foo: 'bar' } }; + clientOpts.baseClient.resolve.mockReturnValue(returnValue as any); + + const result = await client.resolve(type, id, options); + // we will never redact the "All Spaces" ID + expect(result).toEqual({ + saved_object: expect.objectContaining({ namespaces: ['*', namespace, '?'] }), + }); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( + 'login:', + ['some-other-namespace'] + // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs + // we don't check privileges for authorizedNamespace either, as that was already checked earlier in the operation + ); + }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = mockResult; + clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.resolve, { type, id, options }, 'resolve'); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_resolve', EventOutcome.SUCCESS, { type, id: resolvedId }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.resolve(type, id, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_resolve', EventOutcome.FAILURE, { type, id }); + }); +}); + describe('#deleteFromNamespaces', () => { const type = 'foo'; const id = `${type}-id`; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index e6e34de4ac9ab..1919317a1048e 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -323,6 +323,42 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } + public async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ) { + try { + const args = { type, id, options }; + await this.ensureAuthorized(type, 'get', options.namespace, { args, auditAction: 'resolve' }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id }, + error, + }) + ); + throw error; + } + + const resolveResult = await this.baseClient.resolve(type, id, options); + + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id: resolveResult.saved_object.id }, + }) + ); + + return { + ...resolveResult, + saved_object: await this.redactSavedObjectNamespaces(resolveResult.saved_object, [ + options.namespace, + ]), + }; + } + public async update( type: string, id: string, diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 4fd9529507335..a79651c1ae9a6 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -103,6 +103,37 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + describe('#resolve', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.resolve('foo', '', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { + saved_object: createMockResponse(), + outcome: 'exactMatch' as 'exactMatch', // outcome doesn't matter, just including it for type safety + }; + baseClient.resolve.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.resolve(type, id, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.resolve).toHaveBeenCalledWith(type, id, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + describe('#bulkGet', () => { test(`throws error if options.namespace is specified`, async () => { const { client } = createSpacesSavedObjectsClient(); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 049bd88085ed5..3219017a83bda 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -246,6 +246,24 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } + /** + * 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 {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} - { saved_object, outcome } + */ + async resolve(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.resolve(type, id, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + /** * Updates an object * From 5db1d2c25f6d2e4e94b465fc78c2728596651117 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 30 Nov 2020 21:16:03 -0500 Subject: [PATCH 08/22] Create LegacyUrlAlias objects during "conversion" transforms --- .../migrations/core/document_migrator.test.ts | 295 +++++++++++------- .../migrations/core/document_migrator.ts | 114 +++++-- .../migrations/core/index_migrator.test.ts | 5 +- .../migrations/core/migrate_raw_docs.test.ts | 46 ++- .../migrations/core/migrate_raw_docs.ts | 12 +- 5 files changed, 314 insertions(+), 158 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 3763c3a78f4ee..544cd4637c696 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -25,6 +25,7 @@ import { DocumentMigrator } from './document_migrator'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectsType } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; const mockLoggerFactory = loggingSystemMock.create(); const mockLogger = mockLoggerFactory.get('mock logger'); @@ -741,14 +742,16 @@ describe('DocumentMigrator', () => { referencesMigrationVersion: kibanaVersion, } as SavedObjectUnsanitizedDoc; const actual = migrator.migrateAndConvert(obj); - expect(actual).toEqual({ - id: 'mischievous', - type: 'dog', - attributes: { name: 'Ann' }, - migrationVersion: { dog: '1.0.0' }, - referencesMigrationVersion: kibanaVersion, - // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario - }); + expect(actual).toEqual([ + { + id: 'mischievous', + type: 'dog', + attributes: { name: 'Ann' }, + migrationVersion: { dog: '1.0.0' }, + referencesMigrationVersion: kibanaVersion, + // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario + }, + ]); }); it('skips reference transforms and conversion transforms when using `migrate`', () => { @@ -800,27 +803,31 @@ describe('DocumentMigrator', () => { it('in the default space', () => { const actual = migrator.migrateAndConvert(obj); expect(mockUuidv5).not.toHaveBeenCalled(); - expect(actual).toEqual({ - id: 'bad', - type: 'dog', - attributes: { name: 'Sweet Peach' }, - references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, - }); + expect(actual).toEqual([ + { + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + }, + ]); }); it('in a non-default space', () => { const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); expect(mockUuidv5).toHaveBeenCalledTimes(1); expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:toy:favorite', 'DNSUUID'); - expect(actual).toEqual({ - id: 'bad', - type: 'dog', - attributes: { name: 'Sweet Peach' }, - references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, - namespace: 'foo-namespace', - }); + expect(actual).toEqual([ + { + id: 'bad', + type: 'dog', + attributes: { name: 'Sweet Peach' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + }, + ]); }); }); @@ -843,29 +850,44 @@ describe('DocumentMigrator', () => { it('in the default space', () => { const actual = migrator.migrateAndConvert(obj); expect(mockUuidv5).not.toHaveBeenCalled(); - expect(actual).toEqual({ - id: 'loud', - type: 'dog', - attributes: { name: 'Wally' }, - migrationVersion: { dog: '1.0.0' }, - referencesMigrationVersion: kibanaVersion, - namespaces: ['default'], - }); + expect(actual).toEqual([ + { + id: 'loud', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: { dog: '1.0.0' }, + referencesMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); }); it('in a non-default space', () => { const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); expect(mockUuidv5).toHaveBeenCalledTimes(1); expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:dog:loud', 'DNSUUID'); - expect(actual).toEqual({ - id: 'uuidv5', - type: 'dog', - attributes: { name: 'Wally' }, - migrationVersion: { dog: '1.0.0' }, - referencesMigrationVersion: kibanaVersion, - namespaces: ['foo-namespace'], - originId: 'loud', - }); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Wally' }, + migrationVersion: { dog: '1.0.0' }, + referencesMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'loud', + }, + { + id: 'foo-namespace:dog:loud', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + referencesMigrationVersion: kibanaVersion, + }, + ]); }); }); @@ -888,15 +910,17 @@ describe('DocumentMigrator', () => { it('in the default space', () => { const actual = migrator.migrateAndConvert(obj); expect(mockUuidv5).not.toHaveBeenCalled(); - expect(actual).toEqual({ - id: 'cute', - type: 'dog', - attributes: { name: 'Too' }, - migrationVersion: { dog: '1.0.0' }, - references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, - namespaces: ['default'], - }); + expect(actual).toEqual([ + { + id: 'cute', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: { dog: '1.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); }); it('in a non-default space', () => { @@ -904,16 +928,29 @@ describe('DocumentMigrator', () => { expect(mockUuidv5).toHaveBeenCalledTimes(2); expect(mockUuidv5).toHaveBeenNthCalledWith(1, 'foo-namespace:toy:favorite', 'DNSUUID'); expect(mockUuidv5).toHaveBeenNthCalledWith(2, 'foo-namespace:dog:cute', 'DNSUUID'); - expect(actual).toEqual({ - id: 'uuidv5', - type: 'dog', - attributes: { name: 'Too' }, - migrationVersion: { dog: '1.0.0' }, - references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, - namespaces: ['foo-namespace'], - originId: 'cute', - }); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Too' }, + migrationVersion: { dog: '1.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'cute', + }, + { + id: 'foo-namespace:dog:cute', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + referencesMigrationVersion: kibanaVersion, + }, + ]); }); }); @@ -943,29 +980,33 @@ describe('DocumentMigrator', () => { it('in the default space', () => { const actual = migrator.migrateAndConvert(obj); expect(mockUuidv5).not.toHaveBeenCalled(); - expect(actual).toEqual({ - id: 'sleepy', - type: 'dog', - attributes: { name: 'Patches' }, - migrationVersion: { dog: '2.0.0' }, - references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, - }); + expect(actual).toEqual([ + { + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + }, + ]); }); it('in a non-default space', () => { const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); expect(mockUuidv5).toHaveBeenCalledTimes(1); expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:toy:favorite', 'DNSUUID'); - expect(actual).toEqual({ - id: 'sleepy', - type: 'dog', - attributes: { name: 'Patches' }, - migrationVersion: { dog: '2.0.0' }, - references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, - namespace: 'foo-namespace', - }); + expect(actual).toEqual([ + { + id: 'sleepy', + type: 'dog', + attributes: { name: 'Patches' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespace: 'foo-namespace', + }, + ]); }); }); @@ -992,29 +1033,44 @@ describe('DocumentMigrator', () => { it('in the default space', () => { const actual = migrator.migrateAndConvert(obj); expect(mockUuidv5).not.toHaveBeenCalled(); - expect(actual).toEqual({ - id: 'hungry', - type: 'dog', - attributes: { name: 'Remy' }, - migrationVersion: { dog: '2.0.0' }, - referencesMigrationVersion: kibanaVersion, - namespaces: ['default'], - }); + expect(actual).toEqual([ + { + id: 'hungry', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: { dog: '2.0.0' }, + referencesMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); }); it('in a non-default space', () => { const actual = migrator.migrateAndConvert({ ...obj, namespace: 'foo-namespace' }); expect(mockUuidv5).toHaveBeenCalledTimes(1); expect(mockUuidv5).toHaveBeenCalledWith('foo-namespace:dog:hungry', 'DNSUUID'); - expect(actual).toEqual({ - id: 'uuidv5', - type: 'dog', - attributes: { name: 'Remy' }, - migrationVersion: { dog: '2.0.0' }, - referencesMigrationVersion: kibanaVersion, - namespaces: ['foo-namespace'], - originId: 'hungry', - }); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Remy' }, + migrationVersion: { dog: '2.0.0' }, + referencesMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'hungry', + }, + { + id: 'foo-namespace:dog:hungry', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + referencesMigrationVersion: kibanaVersion, + }, + ]); }); }); @@ -1045,15 +1101,17 @@ describe('DocumentMigrator', () => { it('in the default space', () => { const actual = migrator.migrateAndConvert(obj); expect(mockUuidv5).not.toHaveBeenCalled(); - expect(actual).toEqual({ - id: 'pretty', - type: 'dog', - attributes: { name: 'Sasha' }, - migrationVersion: { dog: '2.0.0' }, - references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, - namespaces: ['default'], - }); + expect(actual).toEqual([ + { + id: 'pretty', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change + referencesMigrationVersion: kibanaVersion, + namespaces: ['default'], + }, + ]); }); it('in a non-default space', () => { @@ -1061,16 +1119,29 @@ describe('DocumentMigrator', () => { expect(mockUuidv5).toHaveBeenCalledTimes(2); expect(mockUuidv5).toHaveBeenNthCalledWith(1, 'foo-namespace:toy:favorite', 'DNSUUID'); expect(mockUuidv5).toHaveBeenNthCalledWith(2, 'foo-namespace:dog:pretty', 'DNSUUID'); - expect(actual).toEqual({ - id: 'uuidv5', - type: 'dog', - attributes: { name: 'Sasha' }, - migrationVersion: { dog: '2.0.0' }, - references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, - namespaces: ['foo-namespace'], - originId: 'pretty', - }); + expect(actual).toEqual([ + { + id: 'uuidv5', + type: 'dog', + attributes: { name: 'Sasha' }, + migrationVersion: { dog: '2.0.0' }, + references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed + referencesMigrationVersion: kibanaVersion, + namespaces: ['foo-namespace'], + originId: 'pretty', + }, + { + id: 'foo-namespace:dog:pretty', + type: LEGACY_URL_ALIAS_TYPE, + attributes: { + targetNamespace: 'foo-namespace', + targetType: 'dog', + targetId: 'uuidv5', + }, + migrationVersion: {}, + referencesMigrationVersion: kibanaVersion, + }, + ]); }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index f3ee38a6372f3..46f5b486105e9 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -72,14 +72,24 @@ import { MigrationLogger } from './migration_logger'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectMigrationFn } from '../types'; import { DEFAULT_NAMESPACE_STRING } from '../../service/lib/utils'; +import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; export type MigrateFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; -export type MigrateAndConvertFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; +export type MigrateAndConvertFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc[]; -type TransformFn = ( - doc: SavedObjectUnsanitizedDoc, - options?: TransformOptions -) => 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 TransformFn = (doc: SavedObjectUnsanitizedDoc, options?: TransformOptions) => TransformResult; interface TransformOptions { convertTypes?: boolean; @@ -101,7 +111,7 @@ interface ActiveMigrations { interface Transform { version: string; - transform: (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; + transform: (doc: SavedObjectUnsanitizedDoc) => TransformResult; transformType: 'migrate' | 'convert' | 'reference'; } @@ -169,22 +179,25 @@ 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. + * 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 => { + public migrateAndConvert = (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc[] => { // 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); - return this.transformDoc(clonedDoc, { convertTypes: true }); + const { transformedDoc, additionalDocs } = this.transformDoc(clonedDoc, { convertTypes: true }); + return [transformedDoc].concat(additionalDocs); }; } @@ -323,18 +336,26 @@ function buildDocumentTransform({ options: TransformOptions = {} ) { const { convertTypes = false } = options; - const result = doc.migrationVersion - ? applyMigrations(doc, migrations, kibanaVersion, convertTypes) - : markAsUpToDate(doc, migrations, kibanaVersion); + let transformedDoc: SavedObjectUnsanitizedDoc; + let additionalDocs: SavedObjectUnsanitizedDoc[] = []; + if (doc.migrationVersion) { + const result = applyMigrations(doc, migrations, kibanaVersion, convertTypes); + 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 }; }; } @@ -344,14 +365,20 @@ function applyMigrations( kibanaVersion: string, convertTypes: boolean ) { + let additionalDocs: SavedObjectUnsanitizedDoc[] = []; while (true) { const prop = nextUnmigratedProp(doc, migrations); if (!prop) { // regardless of whether or not any reference transform was applied, update the referencesMigrationVersion // this is needed to ensure that newly created documents have an up-to-date referencesMigrationVersion field - return { ...doc, referencesMigrationVersion: kibanaVersion }; + return { + transformedDoc: { ...doc, referencesMigrationVersion: kibanaVersion }, + additionalDocs, + }; } - doc = migrateProp(doc, prop, migrations, convertTypes); + const result = migrateProp(doc, prop, migrations, convertTypes); + doc = result.transformedDoc; + additionalDocs = additionalDocs.concat(result.additionalDocs); } } @@ -399,16 +426,35 @@ function markAsUpToDate( */ function convertType(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 { ...otherAttrs, namespaces: [DEFAULT_NAMESPACE_STRING] }; + return { + transformedDoc: { ...otherAttrs, namespaces: [DEFAULT_NAMESPACE_STRING] }, + additionalDocs, + }; } const { id: originId, type } = otherAttrs; // Deterministically generate a new ID for this object; the uuidv5 namespace constant (uuidv5.DNS) is arbitrary const id = uuidv5(`${namespace}:${type}:${originId}`, uuidv5.DNS); - return { ...otherAttrs, id, originId, namespaces: [namespace] }; + if (namespace !== undefined) { + const legacyUrlAlias: SavedObjectUnsanitizedDoc = { + 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, + }; } /** @@ -446,15 +492,18 @@ function getReferenceTransforms(typeRegistry: ISavedObjectTypeRegistry): Transfo const { namespace, references } = doc; if (namespace && references?.length) { return { - ...doc, - references: references.map(({ type, id, ...attrs }) => ({ - ...attrs, - type, - id: val.has(type) ? uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS) : id, - })), + transformedDoc: { + ...doc, + references: references.map(({ type, id, ...attrs }) => ({ + ...attrs, + type, + id: val.has(type) ? uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS) : id, + })), + }, + additionalDocs: [], }; } - return doc; + return { transformedDoc: doc, additionalDocs: [] }; }, transformType: 'reference', })); @@ -506,7 +555,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); @@ -569,9 +618,10 @@ function migrateProp( prop: string, migrations: ActiveMigrations, convertTypes: boolean -): SavedObjectUnsanitizedDoc { +): TransformResult { const originalType = doc.type; let migrationVersion = _.clone(doc.migrationVersion) || {}; + let additionalDocs: SavedObjectUnsanitizedDoc[] = []; for (const { version, transform, transformType } of applicableTransforms(migrations, doc, prop)) { const currentVersion = propVersion(doc, prop); @@ -582,7 +632,9 @@ function migrateProp( if (transformType === 'migrate' || convertTypes) { // migrate transforms are always applied, but conversion transforms and reference transforms are only applied when Kibana is upgraded - doc = transform(doc); + const result = transform(doc); + doc = result.transformedDoc; + additionalDocs = additionalDocs.concat(result.additionalDocs); } if (transformType === 'reference') { // regardless of whether or not the reference transform was applied, increment the version @@ -598,7 +650,7 @@ function migrateProp( } } - return doc; + return { transformedDoc: doc, additionalDocs }; } /** diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 9af1b9c6796e9..5ef7a580dc31d 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -326,10 +326,7 @@ describe('IndexMigrator', () => { let count = 0; const { client } = testOpts; const migrateAndConvertDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { - return { - ...doc, - attributes: { name: ++count }, - }; + return [{ ...doc, attributes: { name: ++count } }]; }); testOpts.documentMigrator = { diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index dd95927b42b59..87be26fa01e45 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -26,9 +26,9 @@ import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { - const transform = jest.fn((doc: any) => - set(_.cloneDeep(doc), 'attributes.name', 'HOI!') - ); + const transform = jest.fn((doc: any) => [ + set(_.cloneDeep(doc), 'attributes.name', 'HOI!'), + ]); const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, @@ -71,9 +71,9 @@ describe('migrateRawDocs', () => { test('passes invalid docs through untouched and logs error', async () => { const logger = createSavedObjectsMigrationLoggerMock(); - const transform = jest.fn((doc: any) => - set(_.cloneDeep(doc), 'attributes.name', 'TADA') - ); + const transform = jest.fn((doc: any) => [ + set(_.cloneDeep(doc), 'attributes.name', 'TADA'), + ]); const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, @@ -105,6 +105,40 @@ describe('migrateRawDocs', () => { expect(logger.error).toBeCalledTimes(1); }); + test('handles when one document is transformed into multiple documents', async () => { + const transform = jest.fn((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((doc: any) => { throw new Error('error during transform'); diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index c6cea07b9fc09..d48e680670597 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -51,10 +51,12 @@ export async function migrateRawDocs( 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( @@ -76,7 +78,7 @@ export async function migrateRawDocs( */ function transformNonBlocking( transform: MigrateAndConvertFn -): (doc: SavedObjectUnsanitizedDoc) => Promise { +): (doc: SavedObjectUnsanitizedDoc) => Promise { // promises aren't enough to unblock the event loop return (doc: SavedObjectUnsanitizedDoc) => new Promise((resolve, reject) => { From 0b5ab24ca6704a127e014f65a81d25c39f8c7e06 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 1 Dec 2020 21:03:06 -0500 Subject: [PATCH 09/22] Add integration tests --- .../apis/saved_objects/index.js | 5 +- .../apis/saved_objects/migrations.ts | 320 +++++++++++++++--- .../apis/saved_objects/resolve.ts | 107 ++++++ .../saved_objects/spaces/data.json | 116 +++++++ .../saved_objects/spaces/mappings.json | 29 ++ .../saved_object_test_plugin/server/plugin.ts | 7 + .../common/suites/resolve.ts | 138 ++++++++ .../security_and_spaces/apis/index.ts | 1 + .../security_and_spaces/apis/resolve.ts | 82 +++++ .../security_only/apis/index.ts | 1 + .../security_only/apis/resolve.ts | 73 ++++ .../spaces_only/apis/index.ts | 1 + .../spaces_only/apis/resolve.ts | 47 +++ 13 files changed, 873 insertions(+), 54 deletions(-) create mode 100644 test/api_integration/apis/saved_objects/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/common/suites/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts create mode 100644 x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts diff --git a/test/api_integration/apis/saved_objects/index.js b/test/api_integration/apis/saved_objects/index.js index ad6c3749181dd..c28683b5709a8 100644 --- a/test/api_integration/apis/saved_objects/index.js +++ b/test/api_integration/apis/saved_objects/index.js @@ -21,15 +21,16 @@ export default function ({ loadTestFile }) { 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')); }); } diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index fb0b062b1dc4c..14973d1c8b49a 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -21,10 +21,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, @@ -40,6 +41,25 @@ import { 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() {}, @@ -73,16 +93,22 @@ export default ({ getService }: FtrProviderContext) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations: Record = { - 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 }); @@ -119,7 +145,7 @@ export default ({ getService }: FtrProviderContext) => { const result = await migrateIndex({ esClient, index, - migrations, + savedObjectTypes, mappingProperties, obsoleteIndexTemplatePattern: 'migration_a*', }); @@ -141,13 +167,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([ @@ -207,28 +227,46 @@ export default ({ getService }: FtrProviderContext) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations: Record = { - 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([ @@ -311,18 +349,21 @@ export default ({ getService }: FtrProviderContext) => { foo: { properties: { name: { type: 'text' } } }, }; - const migrations: Record = { - 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 @@ -361,6 +402,166 @@ export default ({ getService }: FtrProviderContext) => { }, ]); }); + + 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' }, + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + id: `foo:${newFooId}`, + type: 'foo', + foo: { name: 'Foo 1 spacex' }, + references: [], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { foo: '1.0.0' }, + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + // new object + id: 'legacy-url-alias:spacex:foo:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newFooId, + targetNamespace: 'spacex', + targetType: 'foo', + }, + migrationVersion: {}, + references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + id: 'bar:1', + type: 'bar', + bar: { nomnom: 1 }, + references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + namespaces: ['default'], + migrationVersion: { bar: '2.0.0' }, + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + 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' }, + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + // new object + id: 'legacy-url-alias:spacex:bar:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newBarId, + targetNamespace: 'spacex', + targetType: 'bar', + }, + migrationVersion: {}, + references: [], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + id: 'baz:1', + type: 'baz', + baz: { title: 'Baz 1 default' }, + references: [{ type: 'bar', id: '1', name: 'Bar 1 default' }], + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + { + id: 'spacex:baz:1', + type: 'baz', + baz: { title: 'Baz 1 spacex' }, + references: [{ type: 'bar', id: newBarId, name: 'Bar 1 spacex' }], + namespace: 'spacex', + referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + }, + ].sort(sortByTypeAndId) + ); + }); }); }; @@ -371,6 +572,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' }, + }, + }, + referencesMigrationVersion: { + type: 'keyword', + }, }; await esClient.indices.create({ index, @@ -400,19 +625,18 @@ async function createDocs({ async function migrateIndex({ esClient, index, - migrations, + savedObjectTypes, mappingProperties, obsoleteIndexTemplatePattern, }: { esClient: ElasticsearchClient; index: string; - migrations: Record; + 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: KIBANA_VERSION, @@ -437,18 +661,6 @@ async function migrateIndex({ return await migrator.migrate(); } -function migrationsToTypes( - migrations: Record -): 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>({ index }); @@ -457,5 +669,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); } diff --git a/test/api_integration/apis/saved_objects/resolve.ts b/test/api_integration/apis/saved_objects/resolve.ts new file mode 100644 index 0000000000000..21e27a57715d1 --- /dev/null +++ b/test/api_integration/apis/saved_objects/resolve.ts @@ -0,0 +1,107 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + const esArchiver = getService('esArchiver'); + + describe('resolve', () => { + 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, + 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, + }); + })); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index d9d5c6f9c5808..32cae675dea74 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -502,3 +502,119 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:exact-match", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome exactMatch" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:alias-match-newid", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome aliasMatch" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:space_1:resolvetype:alias-match", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "targetNamespace": "space_1", + "targetType": "resolvetype", + "targetId": "alias-match-newid" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:space_1:resolvetype:disabled", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "targetNamespace": "space_1", + "targetType": "resolvetype", + "targetId": "alias-match-newid", + "disabled": true + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:conflict", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome conflict (1 of 2)" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "resolvetype:conflict-newid", + "source": { + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "resolvetype": { + "title": "Resolve outcome conflict (2 of 2)" + }, + "namespaces": ["default", "space_1", "space_2"] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:space_1:resolvetype:conflict", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "targetNamespace": "space_1", + "targetType": "resolvetype", + "targetId": "conflict-newid" + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 73f0e536b9295..561c2ecc56fa2 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -176,6 +176,28 @@ } } }, + "legacy-url-alias": { + "properties": { + "targetNamespace": { + "type": "keyword" + }, + "targetType": { + "type": "keyword" + }, + "targetId": { + "type": "keyword" + }, + "lastResolved": { + "type": "date" + }, + "resolveCounter": { + "type": "integer" + }, + "disabled": { + "type": "boolean" + } + } + }, "namespace": { "type": "keyword" }, @@ -185,6 +207,13 @@ "originId": { "type": "keyword" }, + "resolvetype": { + "properties": { + "title": { + "type": "text" + } + } + }, "search": { "properties": { "columns": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index 45880635586a7..d311e539b1687 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -64,6 +64,13 @@ export class Plugin { namespaceType: 'single', mappings, }); + core.savedObjects.registerType({ + name: 'resolvetype', + hidden: false, + namespaceType: 'multiple', + management, + mappings, + }); } public start() { diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts new file mode 100644 index 0000000000000..250a3b19710a9 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { SuperTest } from 'supertest'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +export interface ResolveTestDefinition extends TestDefinition { + request: { type: string; id: string }; +} +export type ResolveTestSuite = TestSuite; +export interface ResolveTestCase extends TestCase { + expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict'; + expectedId?: string; +} + +const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + +export const TEST_CASES = Object.freeze({ + EXACT_MATCH: Object.freeze({ + type: 'resolvetype', + id: 'exact-match', + expectedNamespaces: EACH_SPACE, + expectedOutcome: 'exactMatch' as 'exactMatch', + expectedId: 'exact-match', + }), + ALIAS_MATCH: Object.freeze({ + type: 'resolvetype', + id: 'alias-match', + expectedNamespaces: EACH_SPACE, + expectedOutcome: 'aliasMatch' as 'aliasMatch', + expectedId: 'alias-match-newid', + }), + CONFLICT: Object.freeze({ + type: 'resolvetype', + id: 'conflict', + expectedNamespaces: EACH_SPACE, + expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists + expectedId: 'conflict', + }), + DISABLED: Object.freeze({ + type: 'resolvetype', + id: 'disabled', + }), + DOES_NOT_EXIST: Object.freeze({ + type: 'resolvetype', + id: 'does-not-exist', + }), + HIDDEN: CASES.HIDDEN, +}); + +export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectSavedObjectForbidden = expectResponses.forbiddenTypes('get'); + const expectResponseBody = (testCase: ResolveTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectSavedObjectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body.saved_object || response.body; // errors do not have a saved_object field + const { expectedId: id, expectedOutcome } = testCase; + await expectResponses.permitted(object, { ...testCase, ...(id && { id }) }); + if (expectedOutcome && !testCase.failure) { + expect(response.body.outcome).to.eql(expectedOutcome); + } + } + }; + const createTestDefinitions = ( + testCases: ResolveTestCase | ResolveTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): ResolveTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map((x) => ({ ...x, failure: 403 })); + } + return cases.map((x) => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); + }; + + const makeResolveTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: ResolveTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/resolve/${type}/${id}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeResolveTest(describe); + // @ts-ignore + addTests.only = makeResolveTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index ed501b235a457..a90b4b857748c 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -28,6 +28,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./resolve')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts new file mode 100644 index 0000000000000..94df364c9017c --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + resolveTestSuiteFactory, + TEST_CASES as CASES, + ResolveTestDefinition, +} from '../../common/suites/resolve'; + +const { + SPACE_1: { spaceId: SPACE_1_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.EXACT_MATCH, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { + ...CASES.CONFLICT, + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }), + }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = resolveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; + + describe('_resolve', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ResolveTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { + _addTests(user, unauthorized); + }); + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach((user) => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts index 997dbef49360f..ae10aa400ed87 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts @@ -28,6 +28,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./resolve')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts new file mode 100644 index 0000000000000..9f37f97881071 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + resolveTestSuiteFactory, + TEST_CASES as CASES, + ResolveTestDefinition, +} from '../../common/suites/resolve'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.EXACT_MATCH }, + { ...CASES.ALIAS_MATCH, ...fail404() }, + { ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as 'exactMatch' }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = resolveTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; + + describe('_resolve', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: ResolveTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach((user) => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts index c8050733fc6e9..137596bc20c4c 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -20,6 +20,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./resolve')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts new file mode 100644 index 0000000000000..a6f76fc80044d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { resolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/resolve'; + +const { + SPACE_1: { spaceId: SPACE_1_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + CASES.EXACT_MATCH, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { + ...CASES.CONFLICT, + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }), + }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = resolveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId }); + }; + + describe('_resolve', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); + }); + }); +} From dd9ecd672ebde50c420b9f2a074831090d40a048 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 1 Dec 2020 21:37:07 -0500 Subject: [PATCH 10/22] Update docs --- docs/api/saved-objects.asciidoc | 3 + docs/api/saved-objects/resolve.asciidoc | 126 ++++++++++++++++++ .../core/server/kibana-plugin-core-server.md | 4 +- ...gin-core-server.savedobjectmigrationmap.md | 2 +- ...a-plugin-core-server.savedobjectsclient.md | 1 + ...-core-server.savedobjectsclient.resolve.md | 26 ++++ ...savedobjectsrawdocparseoptions.flexible.md | 13 ++ ...e-server.savedobjectsrawdocparseoptions.md | 20 +++ ...ugin-core-server.savedobjectsrepository.md | 1 + ...e-server.savedobjectsrepository.resolve.md | 28 ++++ ...core-server.savedobjectsresolveresponse.md | 20 +++ ...ver.savedobjectsresolveresponse.outcome.md | 11 ++ ...avedobjectsresolveresponse.saved_object.md | 11 ++ ...sserializer.generaterawlegacyurlaliasid.md | 26 ++++ ...savedobjectsserializer.israwsavedobject.md | 5 +- ...ugin-core-server.savedobjectsserializer.md | 5 +- ...savedobjectsserializer.rawtosavedobject.md | 3 +- ...type.converttomultinamespacetypeversion.md | 42 ++++++ ...ana-plugin-core-server.savedobjectstype.md | 22 +++ ...plugin-plugins-data-server.plugin.start.md | 4 +- src/core/server/server.api.md | 21 ++- src/plugins/data/server/server.api.md | 2 +- 22 files changed, 384 insertions(+), 12 deletions(-) create mode 100644 docs/api/saved-objects/resolve.asciidoc create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md diff --git a/docs/api/saved-objects.asciidoc b/docs/api/saved-objects.asciidoc index 0d8ceefb47e91..ecf975134c64a 100644 --- a/docs/api/saved-objects.asciidoc +++ b/docs/api/saved-objects.asciidoc @@ -10,6 +10,8 @@ The following saved objects APIs are available: * <> to retrieve a single {kib} saved object by ID +* <> to retrieve a single {kib} saved object by ID, using any legacy URL alias if it exists + * <> to retrieve multiple {kib} saved objects by ID * <> 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[] diff --git a/docs/api/saved-objects/resolve.asciidoc b/docs/api/saved-objects/resolve.asciidoc new file mode 100644 index 0000000000000..2fee2209b2759 --- /dev/null +++ b/docs/api/saved-objects/resolve.asciidoc @@ -0,0 +1,126 @@ +[[saved-objects-api-resolve]] +=== Resolve object API +++++ +Resolve object +++++ + +experimental[] Retrieve a single {kib} saved object by ID, using any legacy URL alias if it exists + +[[saved-objects-api-resolve-request]] +==== Request + +`GET :/api/saved_objects/resolve//` + +`GET :/s//api/saved_objects/resolve//` + +[[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": "[{\"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\"}]", + "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": "exactMatch" +} +-------------------------------------------------- diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index adbb2460dc80a..a44dd47413f13 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -139,7 +139,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObject](./kibana-plugin-core-server.savedobject.md) | | | [SavedObjectAttributes](./kibana-plugin-core-server.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | | [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) | | @@ -182,10 +182,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). | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md index 016442ce67d43..5dce93fccd182 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationmap.md @@ -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. diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 7fb34631c736e..da1f4d029ea2b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -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 | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md new file mode 100644 index 0000000000000..b9a63f0b8c05a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.resolve.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [resolve](./kibana-plugin-core-server.savedobjectsclient.resolve.md) + +## SavedObjectsClient.resolve() method + +Resolves a single object, using any legacy URL alias if it exists + +Signature: + +```typescript +resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | The type of SavedObject to retrieve | +| id | string | The ID of the SavedObject to retrieve | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md new file mode 100644 index 0000000000000..dd365a92c62df --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) > [flexible](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md) + +## SavedObjectsRawDocParseOptions.flexible property + +Optional flag to allow for flexible 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. + +Signature: + +```typescript +flexible?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md new file mode 100644 index 0000000000000..96b600eb1c480 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [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. + +Signature: + +```typescript +export interface SavedObjectsRawDocParseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [flexible](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md) | boolean | Optional flag to allow for flexible 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. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index e0a6b8af5658a..af30d484d7fc0 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -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, counterFieldNames, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields by one. 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 | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md new file mode 100644 index 0000000000000..7d0a1c7d204be --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.resolve.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [resolve](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) + +## SavedObjectsRepository.resolve() method + +Resolves a single object, using any legacy URL alias if it exists + +Signature: + +```typescript +resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise>` + +{promise} - { saved\_object, outcome } + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md new file mode 100644 index 0000000000000..68102f256fa3b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) + +## SavedObjectsResolveResponse interface + + +Signature: + +```typescript +export interface SavedObjectsResolveResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | | +| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md new file mode 100644 index 0000000000000..4bfe1d5325985 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) + +## SavedObjectsResolveResponse.outcome property + +Signature: + +```typescript +outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md new file mode 100644 index 0000000000000..c184312675f75 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) + +## SavedObjectsResolveResponse.saved\_object property + +Signature: + +```typescript +saved_object: SavedObject; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md new file mode 100644 index 0000000000000..d33f42ee2cf5f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawlegacyurlaliasid.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsSerializer](./kibana-plugin-core-server.savedobjectsserializer.md) > [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. + +Signature: + +```typescript +generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| namespace | string | | +| type | string | | +| id | string | | + +Returns: + +`string` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md index b9033b00624cc..1094cc25ab557 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.israwsavedobject.md @@ -9,14 +9,15 @@ Determines whether or not the raw document can be converted to a saved object. Signature: ```typescript -isRawSavedObject(rawDoc: SavedObjectsRawDoc): boolean; +isRawSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): boolean; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| rawDoc | SavedObjectsRawDoc | | +| doc | SavedObjectsRawDoc | | +| options | SavedObjectsRawDocParseOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md index 129e6d8bf90f8..c7fa5fc85c613 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.md @@ -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. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md index dc9a2ef85839f..3fc386f263141 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md @@ -9,7 +9,7 @@ Converts a document from the format that is stored in elasticsearch to the saved Signature: ```typescript -rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc; +rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; ``` ## Parameters @@ -17,6 +17,7 @@ rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc; | Parameter | Type | Description | | --- | --- | --- | | doc | SavedObjectsRawDoc | | +| options | SavedObjectsRawDocParseOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md new file mode 100644 index 0000000000000..064bd0b35699d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md @@ -0,0 +1,42 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) > [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. + +Signature: + +```typescript +convertToMultiNamespaceTypeVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index a8894286de910..e2c858512c54c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -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) | string | If defined, will be used to convert the type to an alias. | +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | 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) | boolean | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). | | [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string | If defined, the type instances will be stored in the given index instead of the default one. | | [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md index 8a3dbe5a6350c..b3a5a9f8300fc 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md @@ -12,7 +12,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("src/core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; @@ -31,7 +31,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("src/core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }` diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 36a8d9a52fd52..13ae85bbde13a 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2000,6 +2000,7 @@ export class SavedObjectsClient { find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; + resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } @@ -2420,6 +2421,11 @@ export interface SavedObjectsRawDoc { _type?: string; } +// @public +export interface SavedObjectsRawDocParseOptions { + flexible?: boolean; +} + // @public (undocumented) export interface SavedObjectsRemoveReferencesToOptions extends SavedObjectsBaseOptions { refresh?: boolean; @@ -2450,6 +2456,7 @@ export class SavedObjectsRepository { get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFieldNames: string[], options?: SavedObjectsIncrementCounterOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; + resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; } @@ -2470,13 +2477,22 @@ export interface SavedObjectsResolveImportErrorsOptions { typeRegistry: ISavedObjectTypeRegistry; } +// @public (undocumented) +export interface SavedObjectsResolveResponse { + // (undocumented) + outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + // (undocumented) + saved_object: SavedObject; +} + // @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; } @@ -2510,6 +2526,7 @@ export interface SavedObjectStatusMeta { // @public (undocumented) export interface SavedObjectsType { convertToAliasScript?: string; + convertToMultiNamespaceTypeVersion?: string; hidden: boolean; indexPattern?: string; management?: SavedObjectsTypeManagementDefinition; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 86ec784834ace..6e021f662cecc 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -962,7 +962,7 @@ export class Plugin implements Plugin_2 Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("src/core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; From 42cc69435d4c3c5c35c06e037c47e4c8e2e1e8d5 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 4 Dec 2020 10:50:32 -0500 Subject: [PATCH 11/22] PR review feedback --- ...core-server.savedobjectsresolveresponse.md | 2 +- ...ver.savedobjectsresolveresponse.outcome.md | 4 ++ .../migrations/core/document_migrator.ts | 38 ++++++++++++------- .../object_types/registration.ts | 24 +++++------- .../saved_objects_service.test.ts | 11 ++++++ .../service/saved_objects_client.ts | 9 +++++ src/core/server/server.api.md | 1 - 7 files changed, 59 insertions(+), 30 deletions(-) diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index 68102f256fa3b..cfb309da0a716 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -15,6 +15,6 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | -| [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | | +| [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | 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. | | [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md index 4bfe1d5325985..eadd85b175375 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md @@ -4,6 +4,10 @@ ## 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. + Signature: ```typescript diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 46f5b486105e9..7d15c770261c1 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -197,7 +197,7 @@ export class DocumentMigrator implements VersionedTransformer { // execute on mutated data the second time. const clonedDoc = _.cloneDeep(doc); const { transformedDoc, additionalDocs } = this.transformDoc(clonedDoc, { convertTypes: true }); - return [transformedDoc].concat(additionalDocs); + return [transformedDoc, ...additionalDocs]; }; } @@ -378,7 +378,7 @@ function applyMigrations( } const result = migrateProp(doc, prop, migrations, convertTypes); doc = result.transformedDoc; - additionalDocs = additionalDocs.concat(result.additionalDocs); + additionalDocs = [...additionalDocs, ...result.additionalDocs]; } } @@ -437,8 +437,7 @@ function convertType(doc: SavedObjectUnsanitizedDoc) { } const { id: originId, type } = otherAttrs; - // Deterministically generate a new ID for this object; the uuidv5 namespace constant (uuidv5.DNS) is arbitrary - const id = uuidv5(`${namespace}:${type}:${originId}`, uuidv5.DNS); + const id = deterministicallyRegenerateObjectId(namespace, type, originId!); if (namespace !== undefined) { const legacyUrlAlias: SavedObjectUnsanitizedDoc = { id: `${namespace}:${type}:${originId}`, @@ -481,13 +480,13 @@ function getReferenceTransforms(typeRegistry: ISavedObjectTypeRegistry): Transfo const transformMap = typeRegistry .getAllTypes() .filter((type) => type.convertToMultiNamespaceTypeVersion) - .reduce((acc, { convertToMultiNamespaceTypeVersion: key, name }) => { - const val = acc.get(key!) ?? new Set(); - return acc.set(key!, val.add(name)); + .reduce((acc, { convertToMultiNamespaceTypeVersion: version, name }) => { + const types = acc.get(version!) ?? new Set(); + return acc.set(version!, types.add(name)); }, new Map>()); - return Array.from(transformMap, ([key, val]) => ({ - version: key, + return Array.from(transformMap, ([version, types]) => ({ + version, transform: (doc) => { const { namespace, references } = doc; if (namespace && references?.length) { @@ -497,7 +496,7 @@ function getReferenceTransforms(typeRegistry: ISavedObjectTypeRegistry): Transfo references: references.map(({ type, id, ...attrs }) => ({ ...attrs, type, - id: val.has(type) ? uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS) : id, + id: types.has(type) ? deterministicallyRegenerateObjectId(namespace, type, id) : id, })), }, additionalDocs: [], @@ -591,7 +590,6 @@ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMi return props(doc).find((p) => { const latestVersion = propVersion(migrations, p); const docVersion = propVersion(doc, p); - const hasPendingReferenceTransform = getHasPendingReferenceTransform(doc, migrations, p); // 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 @@ -606,7 +604,10 @@ function nextUnmigratedProp(doc: SavedObjectUnsanitizedDoc, migrations: ActiveMi ); } - return (latestVersion && latestVersion !== docVersion) || hasPendingReferenceTransform; + return ( + (latestVersion && latestVersion !== docVersion) || + getHasPendingReferenceTransform(doc, migrations, p) // If the object itself is up-to-date, check if its references are up-to-date too + ); }); } @@ -634,7 +635,7 @@ function migrateProp( // migrate transforms are always applied, but conversion transforms and reference transforms are only applied when Kibana is upgraded const result = transform(doc); doc = result.transformedDoc; - additionalDocs = additionalDocs.concat(result.additionalDocs); + additionalDocs = [...additionalDocs, ...result.additionalDocs]; } if (transformType === 'reference') { // regardless of whether or not the reference transform was applied, increment the version @@ -716,3 +717,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 +} diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts index 5bb9981d88e65..7cf43d1fb95d2 100644 --- a/src/core/server/saved_objects/object_types/registration.ts +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -18,17 +18,16 @@ */ import { LEGACY_URL_ALIAS_TYPE } from './constants'; -import { ISavedObjectTypeRegistry, SavedObjectTypeRegistry } from '..'; +import { ISavedObjectTypeRegistry, SavedObjectsType, SavedObjectTypeRegistry } from '..'; -const legacyUrlAliasMappings = { - properties: { - targetNamespace: { type: 'keyword' }, - targetType: { type: 'keyword' }, - targetId: { type: 'keyword' }, - lastResolved: { type: 'date' }, - resolveCounter: { type: 'integer' }, - disabled: { type: 'boolean' }, +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, }; /** @@ -37,10 +36,5 @@ const legacyUrlAliasMappings = { export function registerCoreObjectTypes( typeRegistry: ISavedObjectTypeRegistry & Pick ) { - typeRegistry.registerType({ - name: LEGACY_URL_ALIAS_TYPE, - namespaceType: 'agnostic', - mappings: legacyUrlAliasMappings, - hidden: true, - }); + typeRegistry.registerType(legacyUrlAliasType); } diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index de57a3c79a8eb..83518aa1541a6 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -39,6 +39,7 @@ 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'); @@ -82,6 +83,16 @@ describe('SavedObjectsService', () => { }); describe('#setup()', () => { + it('calls registerCoreObjectTypes', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + + const mockedRegisterCoreObjectTypes = registerCoreObjectTypes as jest.Mock; + 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(); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 69bf948dc75c9..4c59ae25925c5 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -290,6 +290,15 @@ export interface SavedObjectsUpdateResponse */ export interface SavedObjectsResolveResponse { saved_object: SavedObject; + /** + * 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'; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index f6579d9fed566..964213175b53b 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2517,7 +2517,6 @@ export interface SavedObjectsResolveImportErrorsOptions { // @public (undocumented) export interface SavedObjectsResolveResponse { - // (undocumented) outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; // (undocumented) saved_object: SavedObject; From 8fbcca721ee935d72416508b6be65cc7a6c6a739 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 4 Dec 2020 14:13:48 -0500 Subject: [PATCH 12/22] More PR review feedback --- docs/user/security/audit-logging.asciidoc | 4 ++ .../migrations/core/document_migrator.ts | 43 ++++++++----- .../serialization/serializer.test.ts | 25 ++++++-- .../saved_objects/serialization/serializer.ts | 63 ++++++++++++++----- 4 files changed, 97 insertions(+), 38 deletions(-) diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index bacd93f585adc..aae810f96ae79 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -131,6 +131,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. diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 7d15c770261c1..daceafa919e3e 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -67,7 +67,11 @@ import _ from 'lodash'; import Semver from 'semver'; import { Logger } from '../../../logging'; import { SavedObjectUnsanitizedDoc } from '../../serialization'; -import { SavedObjectsMigrationVersion, SavedObjectsType } from '../../types'; +import { + SavedObjectsMigrationVersion, + SavedObjectsNamespaceType, + SavedObjectsType, +} from '../../types'; import { MigrationLogger } from './migration_logger'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectMigrationFn } from '../types'; @@ -89,10 +93,13 @@ interface TransformResult { additionalDocs: SavedObjectUnsanitizedDoc[]; } -type TransformFn = (doc: SavedObjectUnsanitizedDoc, options?: TransformOptions) => TransformResult; +type ApplyTransformsFn = ( + doc: SavedObjectUnsanitizedDoc, + options?: TransformOptions +) => TransformResult; interface TransformOptions { - convertTypes?: boolean; + convertNamespaceTypes?: boolean; } interface DocumentMigratorOptions { @@ -129,7 +136,7 @@ export interface VersionedTransformer { */ export class DocumentMigrator implements VersionedTransformer { private migrations: ActiveMigrations; - private transformDoc: TransformFn; + private transformDoc: ApplyTransformsFn; /** * Creates an instance of DocumentMigrator. @@ -196,7 +203,9 @@ 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); - const { transformedDoc, additionalDocs } = this.transformDoc(clonedDoc, { convertTypes: true }); + const { transformedDoc, additionalDocs } = this.transformDoc(clonedDoc, { + convertNamespaceTypes: true, + }); return [transformedDoc, ...additionalDocs]; }; } @@ -234,7 +243,7 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry, kibanaV } function assertValidConvertToMultiNamespaceType( - namespaceType: string, + namespaceType: SavedObjectsNamespaceType, convertToMultiNamespaceTypeVersion: string, type: string ) { @@ -330,16 +339,16 @@ function buildDocumentTransform({ }: { kibanaVersion: string; migrations: ActiveMigrations; -}): TransformFn { +}): ApplyTransformsFn { return function transformAndValidate( doc: SavedObjectUnsanitizedDoc, options: TransformOptions = {} ) { - const { convertTypes = false } = options; + const { convertNamespaceTypes = false } = options; let transformedDoc: SavedObjectUnsanitizedDoc; let additionalDocs: SavedObjectUnsanitizedDoc[] = []; if (doc.migrationVersion) { - const result = applyMigrations(doc, migrations, kibanaVersion, convertTypes); + const result = applyMigrations(doc, migrations, kibanaVersion, convertNamespaceTypes); transformedDoc = result.transformedDoc; additionalDocs = additionalDocs.concat( result.additionalDocs.map((x) => markAsUpToDate(x, migrations, kibanaVersion)) @@ -363,7 +372,7 @@ function applyMigrations( doc: SavedObjectUnsanitizedDoc, migrations: ActiveMigrations, kibanaVersion: string, - convertTypes: boolean + convertNamespaceTypes: boolean ) { let additionalDocs: SavedObjectUnsanitizedDoc[] = []; while (true) { @@ -376,7 +385,7 @@ function applyMigrations( additionalDocs, }; } - const result = migrateProp(doc, prop, migrations, convertTypes); + const result = migrateProp(doc, prop, migrations, convertNamespaceTypes); doc = result.transformedDoc; additionalDocs = [...additionalDocs, ...result.additionalDocs]; } @@ -394,7 +403,7 @@ function props(doc: SavedObjectUnsanitizedDoc) { */ function propVersion(doc: SavedObjectUnsanitizedDoc | ActiveMigrations, prop: string) { return ( - ((doc as any)[prop] && (doc as any)[prop].latestVersion) || + (doc.hasOwnProperty(prop) && (doc as any)[prop].latestVersion) || (doc.migrationVersion && (doc as any).migrationVersion[prop]) ); } @@ -424,7 +433,7 @@ function markAsUpToDate( * 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 convertType(doc: SavedObjectUnsanitizedDoc) { +function convertNamespaceType(doc: SavedObjectUnsanitizedDoc) { const { namespace, ...otherAttrs } = doc; const additionalDocs: SavedObjectUnsanitizedDoc[] = []; @@ -467,7 +476,7 @@ function getConversionTransforms(type: SavedObjectsType): Transform[] { return [ { version: convertToMultiNamespaceTypeVersion, - transform: convertType, + transform: convertNamespaceType, transformType: 'convert', }, ]; @@ -571,7 +580,7 @@ function getHasPendingReferenceTransform( migrations: ActiveMigrations, prop: string ) { - if (!migrations[prop]) { + if (!migrations.hasOwnProperty(prop)) { return false; } @@ -618,7 +627,7 @@ function migrateProp( doc: SavedObjectUnsanitizedDoc, prop: string, migrations: ActiveMigrations, - convertTypes: boolean + convertNamespaceTypes: boolean ): TransformResult { const originalType = doc.type; let migrationVersion = _.clone(doc.migrationVersion) || {}; @@ -631,7 +640,7 @@ function migrateProp( break; } - if (transformType === 'migrate' || convertTypes) { + if (transformType === 'migrate' || convertNamespaceTypes) { // migrate transforms are always applied, but conversion transforms and reference transforms are only applied when Kibana is upgraded const result = transform(doc); doc = result.transformedDoc; diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 60e983efb603a..e98ce36ac34a4 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -450,20 +450,35 @@ describe('#rawToSavedObject', () => { describe('with "flexible" option enabled', () => { const options = { flexible: true }; - test(`removes type prefix from _id`, () => { + 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`, () => { + 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(`copies _source.namespace to namespace if "flexible" option is enabled`, () => { - const _actual = multiNamespaceSerializer.rawToSavedObject(raw, options); - expect(_actual).toHaveProperty('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'); }); }); }); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 179733e6f4265..beb992bad2c03 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -75,7 +75,6 @@ export class SavedObjectsSerializer { const { _id, _source, _seq_no, _primary_term } = doc; const { type, - namespace, namespaces, originId, migrationVersion, @@ -87,12 +86,13 @@ export class SavedObjectsSerializer { _seq_no != null || _primary_term != null ? encodeVersion(_seq_no!, _primary_term!) : undefined; + const { id, namespace } = this.trimIdPrefix(_source.namespace, type, _id, flexible); const includeNamespace = namespace && (flexible || this.registry.isSingleNamespace(type)); const includeNamespaces = this.registry.isMultiNamespace(type); return { type, - id: this.trimIdPrefix(namespace, type, _id, flexible), + id, ...(includeNamespace && { namespace }), ...(includeNamespaces && { namespaces }), ...(originId && { originId }), @@ -168,35 +168,66 @@ export class SavedObjectsSerializer { return `${LEGACY_URL_ALIAS_TYPE}:${namespace}:${type}:${id}`; } - private trimIdPrefix(namespace: string | undefined, type: string, id: string, flexible: boolean) { + /** + * 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, + flexible: boolean + ) { assertNonEmptyString(id, 'document id'); assertNonEmptyString(type, 'saved object type'); - const { prefix, idMatchesPrefix } = this.parseIdPrefix(namespace, type, id, flexible); - return idMatchesPrefix ? id.slice(prefix.length) : id; + const { prefix, idMatchesPrefix, namespace } = this.parseIdPrefix( + sourceNamespace, + type, + id, + flexible + ); + return { + id: idMatchesPrefix ? id.slice(prefix.length) : id, + namespace, + }; } private parseIdPrefix( - namespace: string | undefined, + sourceNamespace: string | undefined, type: string, id: string, flexible: boolean ) { - const namespacePrefix = - namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; - let prefix = `${namespacePrefix}${type}:`; - - let idMatchesPrefix = id.startsWith(prefix); - if (!idMatchesPrefix && namespace && flexible && this.registry.isMultiNamespace(type)) { - // retry checking the prefix by treating this raw ID as the single-namespace ID format - prefix = `${namespace}:${type}:`; - idMatchesPrefix = id.startsWith(prefix); + 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 = flexible && this.registry.isMultiNamespace(type); + if (sourceNamespace && (this.registry.isSingleNamespace(type) || parseFlexibly)) { + prefix = `${sourceNamespace}:${type}:`; + if (parseFlexibly && !getIdMatchesPrefix(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 { prefix, idMatchesPrefix }; + return { + prefix, + idMatchesPrefix: getIdMatchesPrefix(id, prefix), + namespace, + }; } } +function getIdMatchesPrefix(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}`); From 66b850d2e46e1a2da479a033be83f1fbd82e9900 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 7 Dec 2020 13:01:48 -0500 Subject: [PATCH 13/22] Fix 500 error The DocumentMigrator would throw an error with one of the recent changes that I made. This was causing tons of tests to fail. I reverted the change and that seems to have resolved the problem. --- .../server/saved_objects/migrations/core/document_migrator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index daceafa919e3e..9a682f969c2e2 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -403,7 +403,7 @@ function props(doc: SavedObjectUnsanitizedDoc) { */ function propVersion(doc: SavedObjectUnsanitizedDoc | ActiveMigrations, prop: string) { return ( - (doc.hasOwnProperty(prop) && (doc as any)[prop].latestVersion) || + ((doc as any)[prop] && (doc as any)[prop].latestVersion) || (doc.migrationVersion && (doc as any).migrationVersion[prop]) ); } From 5589d316d2cd48a2de4526e447cbc4f2009c566b Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 15 Dec 2020 17:57:44 -0500 Subject: [PATCH 14/22] Fix merge problems --- .../server/saved_objects/migrations/kibana/kibana_migrator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 326b38e32563b..18fc610a2c739 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -185,7 +185,7 @@ export class KibanaMigrator { transformRawDocs: (rawDocs: SavedObjectsRawDoc[]) => migrateRawDocs( this.serializer, - this.documentMigrator.migrate, + this.documentMigrator.migrateAndConvert, rawDocs, new MigrationLogger(this.log) ), From f7b8d4080386d87803e8374b5aa1661d0b8fd7d2 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 16 Dec 2020 09:12:36 -0500 Subject: [PATCH 15/22] Add usage data collection for saved objects `resolve` API --- .../core_usage_stats_client.mock.ts | 1 + .../core_usage_stats_client.test.ts | 76 +++++++++++++++++++ .../core_usage_stats_client.ts | 6 ++ src/core/server/core_usage_data/types.ts | 7 ++ src/core/server/saved_objects/routes/index.ts | 2 +- .../routes/integration_tests/resolve.test.ts | 9 ++- .../server/saved_objects/routes/resolve.ts | 11 ++- .../collectors/core/core_usage_collector.ts | 7 ++ src/plugins/telemetry/schema/oss_plugins.json | 21 +++++ 9 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/core/server/core_usage_data/core_usage_stats_client.mock.ts b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts index ef350a9bb4c5c..9e6256a63ec5a 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.mock.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts @@ -29,6 +29,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), diff --git a/src/core/server/core_usage_data/core_usage_stats_client.test.ts b/src/core/server/core_usage_data/core_usage_stats_client.test.ts index 6b6e83f168f77..1644d0134fec0 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.test.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.test.ts @@ -31,6 +31,7 @@ import { DELETE_STATS_PREFIX, FIND_STATS_PREFIX, GET_STATS_PREFIX, + RESOLVE_STATS_PREFIX, UPDATE_STATS_PREFIX, IMPORT_STATS_PREFIX, RESOLVE_IMPORT_STATS_PREFIX, @@ -605,6 +606,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(); diff --git a/src/core/server/core_usage_data/core_usage_stats_client.ts b/src/core/server/core_usage_data/core_usage_stats_client.ts index c8d48597fae88..c6a83016db7c6 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.ts @@ -50,6 +50,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'; @@ -63,6 +64,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), @@ -133,6 +135,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); } diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts index b7952334b4be4..b3b27828b0ee3 100644 --- a/src/core/server/core_usage_data/types.ts +++ b/src/core/server/core_usage_data/types.ts @@ -77,6 +77,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; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index a3e0f1e4b8fa7..71ae4dd1f8d73 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -53,7 +53,7 @@ export function registerRoutes({ const router = http.createRouter('/api/saved_objects/'); registerGetRoute(router, { coreUsageData }); - registerResolveRoute(router); + registerResolveRoute(router, { coreUsageData }); registerCreateRoute(router, { coreUsageData }); registerDeleteRoute(router, { coreUsageData }); registerFindRoute(router, { coreUsageData }); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts index 8ab5ea138c967..440805fdea7f3 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts @@ -21,6 +21,9 @@ 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'; @@ -32,6 +35,7 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => { let httpSetup: InternalHttpServiceSetup; let handlerContext: ReturnType; let savedObjectsClient: ReturnType; + let coreUsageStatsClient: jest.Mocked; beforeEach(async () => { const coreContext = createCoreContext({ coreId }); @@ -50,7 +54,10 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => { }); const router = httpSetup.createRouter('/api/saved_objects/'); - registerResolveRoute(router); + 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(); }); diff --git a/src/core/server/saved_objects/routes/resolve.ts b/src/core/server/saved_objects/routes/resolve.ts index 2967273457dbc..afaecef7b2d0c 100644 --- a/src/core/server/saved_objects/routes/resolve.ts +++ b/src/core/server/saved_objects/routes/resolve.ts @@ -19,8 +19,13 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; +import { CoreUsageDataSetup } from '../../core_usage_data'; -export const registerResolveRoute = (router: IRouter) => { +interface RouteDependencies { + coreUsageData: CoreUsageDataSetup; +} + +export const registerResolveRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { router.get( { path: '/resolve/{type}/{id}', @@ -33,6 +38,10 @@ export const registerResolveRoute = (router: IRouter) => { }, 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 }); }) diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index a0960b30a2e87..fec0c850dbdf3 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -165,6 +165,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' }, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index dd3c06a159b81..2da7fbb771cc3 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -3580,6 +3580,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" }, From abd7d2bc1c305215032684c61f32ce56bf7a6f89 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 16 Dec 2020 09:45:32 -0500 Subject: [PATCH 16/22] Rename things based on review feedback * Change serializer `flexible` parameter to `namespaceTreatment` * Rename serializer `getIdMatchesPrefix` function to `checkIdMatchesPrefix` * Rename saved object `referencesMigrationVersion` field to `coreMigrationVersion` --- ...savedobjectsrawdocparseoptions.flexible.md | 13 ---- ...e-server.savedobjectsrawdocparseoptions.md | 2 +- ...tsrawdocparseoptions.namespacetreatment.md | 15 +++++ .../build_active_mappings.test.ts.snap | 16 ++--- .../migrations/core/build_active_mappings.ts | 2 +- .../migrations/core/document_migrator.test.ts | 66 +++++++++---------- .../migrations/core/document_migrator.ts | 16 ++--- .../migrations/core/elastic_index.test.ts | 2 +- .../migrations/core/elastic_index.ts | 2 +- .../migrations/core/index_migrator.test.ts | 12 ++-- .../migrations/core/migrate_raw_docs.ts | 2 +- .../kibana_migrator.test.ts.snap | 8 +-- .../serialization/serializer.test.ts | 30 ++++----- .../saved_objects/serialization/serializer.ts | 33 +++++----- .../saved_objects/serialization/types.ts | 8 ++- .../service/lib/repository.test.js | 4 +- .../saved_objects/service/lib/repository.ts | 2 +- src/core/server/server.api.md | 16 ++++- .../apis/saved_objects/migrations.ts | 46 ++++++------- 19 files changed, 157 insertions(+), 138 deletions(-) delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md deleted file mode 100644 index dd365a92c62df..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) > [flexible](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md) - -## SavedObjectsRawDocParseOptions.flexible property - -Optional flag to allow for flexible 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. - -Signature: - -```typescript -flexible?: boolean; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md index 96b600eb1c480..708d1bc9c514d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.md @@ -16,5 +16,5 @@ export interface SavedObjectsRawDocParseOptions | Property | Type | Description | | --- | --- | --- | -| [flexible](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.flexible.md) | boolean | Optional flag to allow for flexible 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. | +| [namespaceTreatment](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md) | 'strict' | 'lax' | 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. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md new file mode 100644 index 0000000000000..c315d78aaf417 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdocparseoptions.namespacetreatment.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) > [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`. + +Signature: + +```typescript +namespaceTreatment?: 'strict' | 'lax'; +``` diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index 32c5f326f0265..9ee998118bde6 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -6,12 +6,12 @@ Object { "migrationMappingPropertyHashes": Object { "aaa": "625b32086eb1d1203564cf85062dd22e", "bbb": "18c78c995965207ed3f6e7fc5c6e55fe", + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", - "referencesMigrationVersion": "2f4316de49999235636386fe51dc06c1", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", }, @@ -24,6 +24,9 @@ Object { "bbb": Object { "type": "long", }, + "coreMigrationVersion": Object { + "type": "keyword", + }, "migrationVersion": Object { "dynamic": "true", "type": "object", @@ -51,9 +54,6 @@ Object { }, "type": "nested", }, - "referencesMigrationVersion": Object { - "type": "keyword", - }, "type": Object { "type": "keyword", }, @@ -68,13 +68,13 @@ exports[`buildActiveMappings handles the \`dynamic\` property of types 1`] = ` Object { "_meta": Object { "migrationMappingPropertyHashes": Object { + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "firstType": "635418ab953d81d93f1190b70a8d3f57", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", - "referencesMigrationVersion": "2f4316de49999235636386fe51dc06c1", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", "type": "2f4316de49999235636386fe51dc06c1", @@ -83,6 +83,9 @@ Object { }, "dynamic": "strict", "properties": Object { + "coreMigrationVersion": Object { + "type": "keyword", + }, "firstType": Object { "dynamic": "strict", "properties": Object { @@ -118,9 +121,6 @@ Object { }, "type": "nested", }, - "referencesMigrationVersion": Object { - "type": "keyword", - }, "secondType": Object { "dynamic": false, "properties": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 511eac17ed358..5c1919c986d01 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -164,7 +164,7 @@ function defaultMapping(): IndexMapping { }, }, }, - referencesMigrationVersion: { + coreMigrationVersion: { type: 'keyword', }, }, diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 544cd4637c696..816f307c95041 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -204,7 +204,7 @@ describe('DocumentMigrator', () => { type: 'user', attributes: { name: 'Chris' }, migrationVersion: { user: '1.2.3' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -255,7 +255,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Tyler' }, migrationVersion: { acl: '2.3.5' }, acl: 'admins-only, sucka!', - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -293,11 +293,11 @@ describe('DocumentMigrator', () => { id: 'me', type: 'user', attributes: { name: 'Tyler' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); - it('assumes documents w/ undefined migrationVersion and correct referencesMigrationVersion are up to date', () => { + it('assumes documents w/ undefined migrationVersion and correct coreMigrationVersion are up to date', () => { const migrator = new DocumentMigrator({ ...testOpts(), typeRegistry: createRegistry( @@ -326,7 +326,7 @@ describe('DocumentMigrator', () => { type: 'user', attributes: { name: 'Tyler' }, bbb: 'Shazm', - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, } as SavedObjectUnsanitizedDoc); expect(actual).toEqual({ id: 'me', @@ -337,7 +337,7 @@ describe('DocumentMigrator', () => { user: '1.0.0', bbb: '2.3.4', }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -364,7 +364,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Callie', b: 'B', c: 'C' }, migrationVersion: { dog: '2.0.1' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -431,7 +431,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Callie', a: 1, b: 2, c: 3 }, migrationVersion: { dog: '10.0.1' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -465,7 +465,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Callie' }, animal: 'Animal: Doggie', migrationVersion: { animal: '1.0.0', dog: '2.2.4' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -492,7 +492,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { title: 'Title: Name: Callie' }, migrationVersion: { dog: '1.0.2' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -525,7 +525,7 @@ describe('DocumentMigrator', () => { type: 'cat', attributes: { name: 'Kitty Callie' }, migrationVersion: { dog: '2.2.4', cat: '1.0.0' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -602,7 +602,7 @@ describe('DocumentMigrator', () => { type: 'cat', attributes: { name: 'Shiny' }, migrationVersion: { cat: '3.0.0' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -627,7 +627,7 @@ describe('DocumentMigrator', () => { type: 'cat', attributes: { name: 'Boo' }, migrationVersion: { cat: '1.0.0', foo: '5.6.7' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }); }); @@ -727,7 +727,7 @@ describe('DocumentMigrator', () => { }); describe('conversion to multi-namespace type', () => { - it('assumes documents w/ undefined migrationVersion and correct referencesMigrationVersion are up to date', () => { + it('assumes documents w/ undefined migrationVersion and correct coreMigrationVersion are up to date', () => { const migrator = new DocumentMigrator({ ...testOpts(), typeRegistry: createRegistry( @@ -739,7 +739,7 @@ describe('DocumentMigrator', () => { id: 'mischievous', type: 'dog', attributes: { name: 'Ann' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, } as SavedObjectUnsanitizedDoc; const actual = migrator.migrateAndConvert(obj); expect(actual).toEqual([ @@ -748,7 +748,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Ann' }, migrationVersion: { dog: '1.0.0' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, // there is no 'namespaces' field because no transforms were applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario }, ]); @@ -778,7 +778,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Leslie' }, migrationVersion: { dog: '1.0.0' }, references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespace: 'foo-namespace', // there is no 'namespaces' field because no conversion transform was applied; this scenario is contrived for a clean test case but is not indicative of a real-world scenario }); @@ -809,7 +809,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Sweet Peach' }, references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }, ]); }); @@ -824,7 +824,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Sweet Peach' }, references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespace: 'foo-namespace', }, ]); @@ -856,7 +856,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Wally' }, migrationVersion: { dog: '1.0.0' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['default'], }, ]); @@ -872,7 +872,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Wally' }, migrationVersion: { dog: '1.0.0' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['foo-namespace'], originId: 'loud', }, @@ -885,7 +885,7 @@ describe('DocumentMigrator', () => { targetId: 'uuidv5', }, migrationVersion: {}, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }, ]); }); @@ -917,7 +917,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Too' }, migrationVersion: { dog: '1.0.0' }, references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['default'], }, ]); @@ -935,7 +935,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Too' }, migrationVersion: { dog: '1.0.0' }, references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['foo-namespace'], originId: 'cute', }, @@ -948,7 +948,7 @@ describe('DocumentMigrator', () => { targetId: 'uuidv5', }, migrationVersion: {}, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }, ]); }); @@ -987,7 +987,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Patches' }, migrationVersion: { dog: '2.0.0' }, references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }, ]); }); @@ -1003,7 +1003,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Patches' }, migrationVersion: { dog: '2.0.0' }, references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespace: 'foo-namespace', }, ]); @@ -1039,7 +1039,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Remy' }, migrationVersion: { dog: '2.0.0' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['default'], }, ]); @@ -1055,7 +1055,7 @@ describe('DocumentMigrator', () => { type: 'dog', attributes: { name: 'Remy' }, migrationVersion: { dog: '2.0.0' }, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['foo-namespace'], originId: 'hungry', }, @@ -1068,7 +1068,7 @@ describe('DocumentMigrator', () => { targetId: 'uuidv5', }, migrationVersion: {}, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }, ]); }); @@ -1108,7 +1108,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Sasha' }, migrationVersion: { dog: '2.0.0' }, references: [{ id: 'favorite', type: 'toy', name: 'BALL!' }], // no change - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['default'], }, ]); @@ -1126,7 +1126,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Sasha' }, migrationVersion: { dog: '2.0.0' }, references: [{ id: 'uuidv5', type: 'toy', name: 'BALL!' }], // changed - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, namespaces: ['foo-namespace'], originId: 'pretty', }, @@ -1139,7 +1139,7 @@ describe('DocumentMigrator', () => { targetId: 'uuidv5', }, migrationVersion: {}, - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }, ]); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index d45f46c455945..4687ac679c939 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -378,10 +378,10 @@ function applyMigrations( while (true) { const prop = nextUnmigratedProp(doc, migrations); if (!prop) { - // regardless of whether or not any reference transform was applied, update the referencesMigrationVersion - // this is needed to ensure that newly created documents have an up-to-date referencesMigrationVersion field + // 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, referencesMigrationVersion: kibanaVersion }, + transformedDoc: { ...doc, coreMigrationVersion: kibanaVersion }, additionalDocs, }; } @@ -422,7 +422,7 @@ function markAsUpToDate( const version = propVersion(migrations, prop); return version ? set(acc, prop, version) : acc; }, {}), - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }; } @@ -585,10 +585,10 @@ function getHasPendingReferenceTransform( } const { latestReferenceVersion } = migrations[prop]; - const { referencesMigrationVersion } = doc; + const { coreMigrationVersion } = doc; return ( latestReferenceVersion && - (!referencesMigrationVersion || Semver.gt(latestReferenceVersion, referencesMigrationVersion)) + (!coreMigrationVersion || Semver.gt(latestReferenceVersion, coreMigrationVersion)) ); } @@ -648,7 +648,7 @@ function migrateProp( } if (transformType === 'reference') { // regardless of whether or not the reference transform was applied, increment the version - doc.referencesMigrationVersion = version; + doc.coreMigrationVersion = version; } else { migrationVersion = updateMigrationVersion(doc, migrationVersion, prop, version); doc.migrationVersion = _.clone(migrationVersion); @@ -672,7 +672,7 @@ function applicableTransforms( prop: string ) { const minVersion = propVersion(doc, prop); - const minReferenceVersion = doc.referencesMigrationVersion || '0.0.0'; + const minReferenceVersion = doc.coreMigrationVersion || '0.0.0'; const { transforms } = migrations[prop]; return minVersion ? transforms.filter(({ version, transformType }) => diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index dbbbec347b69c..33f1d62f2aa6f 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -727,7 +727,7 @@ describe('ElasticIndex', () => { bool: { must_not: { term: { - referencesMigrationVersion: '7.10.0', + coreMigrationVersion: '7.10.0', }, }, }, diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index ead2a635a5a0e..df014c071d232 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -194,7 +194,7 @@ export async function migrationsUpToDate( bool: { must_not: { term: { - referencesMigrationVersion: kibanaVersion, + coreMigrationVersion: kibanaVersion, }, }, }, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 5ef7a580dc31d..c29274de05962 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -70,7 +70,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', - referencesMigrationVersion: '2f4316de49999235636386fe51dc06c1', + coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -91,7 +91,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, - referencesMigrationVersion: { type: 'keyword' }, + coreMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -193,7 +193,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', - referencesMigrationVersion: '2f4316de49999235636386fe51dc06c1', + coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -215,7 +215,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, - referencesMigrationVersion: { type: 'keyword' }, + coreMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -256,7 +256,7 @@ describe('IndexMigrator', () => { namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', - referencesMigrationVersion: '2f4316de49999235636386fe51dc06c1', + coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -278,7 +278,7 @@ describe('IndexMigrator', () => { id: { type: 'keyword' }, }, }, - referencesMigrationVersion: { type: 'keyword' }, + coreMigrationVersion: { type: 'keyword' }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index d48e680670597..964e6f3189b45 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -46,7 +46,7 @@ export async function migrateRawDocs( const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc); const processedDocs = []; for (const raw of rawDocs) { - const options = { flexible: true }; + const options = { namespaceTreatment: 'lax' as 'lax' }; if (serializer.isRawSavedObject(raw, options)) { const savedObject = serializer.rawToSavedObject(raw, options); savedObject.migrationVersion = savedObject.migrationVersion || {}; diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 29e2c4970f982..32c2536ab0296 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -6,12 +6,12 @@ Object { "migrationMappingPropertyHashes": Object { "amap": "510f1f0adb69830cf8a1c5ce2923ed82", "bmap": "510f1f0adb69830cf8a1c5ce2923ed82", + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", - "referencesMigrationVersion": "2f4316de49999235636386fe51dc06c1", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", }, @@ -32,6 +32,9 @@ Object { }, }, }, + "coreMigrationVersion": Object { + "type": "keyword", + }, "migrationVersion": Object { "dynamic": "true", "type": "object", @@ -59,9 +62,6 @@ Object { }, "type": "nested", }, - "referencesMigrationVersion": Object { - "type": "keyword", - }, "type": Object { "type": "keyword", }, diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 4e6faf4aaeaa3..9fd8b89eb95e2 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -143,25 +143,25 @@ describe('#rawToSavedObject', () => { expect(expected).toEqual(actual); }); - test('if specified it copies the _source.referencesMigrationVersion property to referencesMigrationVersion', () => { + test('if specified it copies the _source.coreMigrationVersion property to coreMigrationVersion', () => { const actual = singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', _source: { type: 'foo', - referencesMigrationVersion: '1.2.3', + coreMigrationVersion: '1.2.3', }, }); - expect(actual).toHaveProperty('referencesMigrationVersion', '1.2.3'); + expect(actual).toHaveProperty('coreMigrationVersion', '1.2.3'); }); - test(`if _source.referencesMigrationVersion is unspecified it doesn't set referencesMigrationVersion`, () => { + 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('referencesMigrationVersion'); + expect(actual).not.toHaveProperty('coreMigrationVersion'); }); test(`if version is unspecified it doesn't set version`, () => { @@ -321,7 +321,7 @@ describe('#rawToSavedObject', () => { foo: '1.2.3', bar: '9.8.7', }, - referencesMigrationVersion: '4.5.6', + coreMigrationVersion: '4.5.6', namespace: 'foo-namespace', updated_at: String(new Date()), references: [], @@ -447,8 +447,8 @@ describe('#rawToSavedObject', () => { expect(actual).not.toHaveProperty('namespace'); }); - describe('with "flexible" option enabled', () => { - const options = { flexible: true }; + 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); @@ -584,23 +584,23 @@ describe('#savedObjectToRaw', () => { expect(actual._source).not.toHaveProperty('migrationVersion'); }); - test('it copies the referencesMigrationVersion property to _source.referencesMigrationVersion', () => { + test('it copies the coreMigrationVersion property to _source.coreMigrationVersion', () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', attributes: {}, - referencesMigrationVersion: '1.2.3', + coreMigrationVersion: '1.2.3', } as any); - expect(actual._source).toHaveProperty('referencesMigrationVersion', '1.2.3'); + expect(actual._source).toHaveProperty('coreMigrationVersion', '1.2.3'); }); - test(`if unspecified it doesn't add referencesMigrationVersion property to _source`, () => { + test(`if unspecified it doesn't add coreMigrationVersion property to _source`, () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', attributes: {}, } as any); - expect(actual._source).not.toHaveProperty('referencesMigrationVersion'); + expect(actual._source).not.toHaveProperty('coreMigrationVersion'); }); test('it decodes the version property to _seq_no and _primary_term', () => { @@ -969,8 +969,8 @@ describe('#isRawSavedObject', () => { ).toBeFalsy(); }); - test('is true if the id is prefixed with type and namespace, and the "flexible" option is enabled', () => { - const options = { flexible: true }; + 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( { diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index bfb379fd254ba..32a2c47d47143 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -50,13 +50,13 @@ export class SavedObjectsSerializer { * @param {SavedObjectsRawDocParseOptions} options - Options for parsing the raw document. */ public isRawSavedObject(doc: SavedObjectsRawDoc, options: SavedObjectsRawDocParseOptions = {}) { - const { flexible = false } = options; + const { namespaceTreatment = 'strict' } = options; const { _id, _source } = doc; const { type, namespace } = _source; if (!type) { return false; } - const { idMatchesPrefix } = this.parseIdPrefix(namespace, type, _id, flexible); + const { idMatchesPrefix } = this.parseIdPrefix(namespace, type, _id, namespaceTreatment); return idMatchesPrefix && _source.hasOwnProperty(type); } @@ -70,7 +70,7 @@ export class SavedObjectsSerializer { doc: SavedObjectsRawDoc, options: SavedObjectsRawDocParseOptions = {} ): SavedObjectSanitizedDoc { - const { flexible = false } = options; + const { namespaceTreatment = 'strict' } = options; const { _id, _source, _seq_no, _primary_term } = doc; const { type, @@ -78,15 +78,16 @@ export class SavedObjectsSerializer { originId, migrationVersion, references, - referencesMigrationVersion, + 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, flexible); - const includeNamespace = namespace && (flexible || this.registry.isSingleNamespace(type)); + 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 { @@ -98,7 +99,7 @@ export class SavedObjectsSerializer { attributes: _source[type], references: references || [], ...(migrationVersion && { migrationVersion }), - ...(referencesMigrationVersion && { referencesMigrationVersion }), + ...(coreMigrationVersion && { coreMigrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), ...(version && { version }), }; @@ -122,7 +123,7 @@ export class SavedObjectsSerializer { updated_at, version, references, - referencesMigrationVersion, + coreMigrationVersion, } = savedObj; const source = { [type]: attributes, @@ -132,7 +133,7 @@ export class SavedObjectsSerializer { ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), - ...(referencesMigrationVersion && { referencesMigrationVersion }), + ...(coreMigrationVersion && { coreMigrationVersion }), ...(updated_at && { updated_at }), }; @@ -176,7 +177,7 @@ export class SavedObjectsSerializer { sourceNamespace: string | undefined, type: string, id: string, - flexible: boolean + namespaceTreatment: 'strict' | 'lax' ) { assertNonEmptyString(id, 'document id'); assertNonEmptyString(type, 'saved object type'); @@ -185,7 +186,7 @@ export class SavedObjectsSerializer { sourceNamespace, type, id, - flexible + namespaceTreatment ); return { id: idMatchesPrefix ? id.slice(prefix.length) : id, @@ -197,14 +198,14 @@ export class SavedObjectsSerializer { sourceNamespace: string | undefined, type: string, id: string, - flexible: boolean + 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 = flexible && this.registry.isMultiNamespace(type); + const parseFlexibly = namespaceTreatment === 'lax' && this.registry.isMultiNamespace(type); if (sourceNamespace && (this.registry.isSingleNamespace(type) || parseFlexibly)) { prefix = `${sourceNamespace}:${type}:`; - if (parseFlexibly && !getIdMatchesPrefix(id, prefix)) { + if (parseFlexibly && !checkIdMatchesPrefix(id, prefix)) { prefix = `${type}:`; } else { // this is either a single-namespace object, or is being converted into a multi-namespace object @@ -217,13 +218,13 @@ export class SavedObjectsSerializer { return { prefix, - idMatchesPrefix: getIdMatchesPrefix(id, prefix), + idMatchesPrefix: checkIdMatchesPrefix(id, prefix), namespace, }; } } -function getIdMatchesPrefix(id: string, prefix: string) { +function checkIdMatchesPrefix(id: string, prefix: string) { return id.startsWith(prefix) && id.length > prefix.length; } diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 8a6245d760a4d..c691837c1045a 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -54,7 +54,7 @@ interface SavedObjectDoc { namespace?: string; namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; - referencesMigrationVersion?: string; + coreMigrationVersion?: string; version?: string; updated_at?: string; originId?: string; @@ -88,9 +88,11 @@ export type SavedObjectSanitizedDoc = SavedObjectDoc & Referenca */ export interface SavedObjectsRawDocParseOptions { /** - * Optional flag to allow for flexible handling of the raw document ID and namespace field. This is needed when a previously + * 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`. */ - flexible?: boolean; + namespaceTreatment?: 'strict' | 'lax'; } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index e16ea4abe1c03..b8f3c4a46493f 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -966,7 +966,7 @@ describe('SavedObjectsRepository', () => { ...response.items[0].create, _source: { ...response.items[0].create._source, - referencesMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation + 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}$/), @@ -975,7 +975,7 @@ describe('SavedObjectsRepository', () => { ...response.items[1].create, _source: { ...response.items[1].create._source, - referencesMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation + coreMigrationVersion: '2.0.0', // the document migrator adds this to all objects before creation namespaces: response.items[1].create._source.namespaces, }, }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 85578a5cb03c2..f1b777a793ea6 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1809,7 +1809,7 @@ export class SavedObjectsRepository { if (this._registry.isSingleNamespace(type)) { savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)]; } - return omit(savedObject, ['namespace', 'referencesMigrationVersion']) as SavedObject; + return omit(savedObject, ['namespace', 'coreMigrationVersion']) as SavedObject; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index fe91de74b5c19..4e4eb0f38bd19 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -679,6 +679,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; @@ -2616,7 +2630,7 @@ export interface SavedObjectsRawDoc { // @public export interface SavedObjectsRawDocParseOptions { - flexible?: boolean; + namespaceTreatment?: 'strict' | 'lax'; } // @public (undocumented) diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 14973d1c8b49a..2c051ff709375 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -177,7 +177,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'bar:o', @@ -185,14 +185,14 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:a', @@ -200,7 +200,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:e', @@ -208,7 +208,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); }); @@ -276,7 +276,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'bar:o', @@ -284,7 +284,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:a', @@ -292,7 +292,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:e', @@ -300,7 +300,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); @@ -312,7 +312,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 68, name: 'NAME i' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'bar:o', @@ -320,7 +320,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 6, name: 'NAME o' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:a', @@ -328,7 +328,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'foo:e', @@ -336,7 +336,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); }); @@ -398,7 +398,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' }, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ]); }); @@ -487,7 +487,7 @@ export default ({ getService }: FtrProviderContext) => { references: [], namespaces: ['default'], migrationVersion: { foo: '1.0.0' }, - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: `foo:${newFooId}`, @@ -497,7 +497,7 @@ export default ({ getService }: FtrProviderContext) => { namespaces: ['spacex'], originId: '1', migrationVersion: { foo: '1.0.0' }, - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { // new object @@ -510,7 +510,7 @@ export default ({ getService }: FtrProviderContext) => { }, migrationVersion: {}, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'bar:1', @@ -519,7 +519,7 @@ export default ({ getService }: FtrProviderContext) => { references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], namespaces: ['default'], migrationVersion: { bar: '2.0.0' }, - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: `bar:${newBarId}`, @@ -529,7 +529,7 @@ export default ({ getService }: FtrProviderContext) => { namespaces: ['spacex'], originId: '1', migrationVersion: { bar: '2.0.0' }, - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { // new object @@ -542,14 +542,14 @@ export default ({ getService }: FtrProviderContext) => { }, migrationVersion: {}, references: [], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'baz:1', type: 'baz', baz: { title: 'Baz 1 default' }, references: [{ type: 'bar', id: '1', name: 'Bar 1 default' }], - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, { id: 'spacex:baz:1', @@ -557,7 +557,7 @@ export default ({ getService }: FtrProviderContext) => { baz: { title: 'Baz 1 spacex' }, references: [{ type: 'bar', id: newBarId, name: 'Bar 1 spacex' }], namespace: 'spacex', - referencesMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here }, ].sort(sortByTypeAndId) ); @@ -593,7 +593,7 @@ async function createIndex({ esClient, index }: { esClient: ElasticsearchClient; id: { type: 'keyword' }, }, }, - referencesMigrationVersion: { + coreMigrationVersion: { type: 'keyword', }, }; From 0b452019dbc31de1db23101e33972f7c4900f9bf Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 11 Jan 2021 14:00:10 -0500 Subject: [PATCH 17/22] More PR review feedback --- docs/api/saved-objects/get.asciidoc | 2 +- docs/api/saved-objects/resolve.asciidoc | 10 +++++++--- .../saved_objects/spaces_saved_objects_client.ts | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/api/saved-objects/get.asciidoc b/docs/api/saved-objects/get.asciidoc index 6aad9759ef5e0..4c8cd020e0286 100644 --- a/docs/api/saved-objects/get.asciidoc +++ b/docs/api/saved-objects/get.asciidoc @@ -78,7 +78,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, diff --git a/docs/api/saved-objects/resolve.asciidoc b/docs/api/saved-objects/resolve.asciidoc index 2fee2209b2759..f2bf31bc5d9e4 100644 --- a/docs/api/saved-objects/resolve.asciidoc +++ b/docs/api/saved-objects/resolve.asciidoc @@ -4,7 +4,11 @@ Resolve object ++++ -experimental[] Retrieve a single {kib} saved object by ID, using any legacy URL alias if it exists +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 @@ -88,7 +92,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, @@ -121,6 +125,6 @@ The API returns the following: "dashboard": "7.0.0" } }, - "outcome": "exactMatch" + "outcome": "conflict" } -------------------------------------------------- diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 3219017a83bda..bd09b8237a468 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -255,7 +255,11 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { * @property {string} [options.namespace] * @returns {promise} - { saved_object, outcome } */ - async resolve(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + public async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ) { throwErrorIfNamespaceSpecified(options); return await this.client.resolve(type, id, { From b88b376880ca57d6c84a496c9afdb13771ee3093 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 14 Jan 2021 15:29:16 -0500 Subject: [PATCH 18/22] Small refactor of document migrator Change some variables and add some code comments to clarify the different types of transforms and how the migrator handles them. --- .../migrations/core/document_migrator.ts | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 4687ac679c939..21244a702287f 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -110,8 +110,10 @@ interface DocumentMigratorOptions { interface ActiveMigrations { [type: string]: { - latestVersion?: string; - latestReferenceVersion?: string; + /** Derived from `migrate` transforms and `convert` transforms */ + latestMigrationVersion?: string; + /** Derived from `reference` transforms */ + latestCoreMigrationVersion?: string; transforms: Transform[]; }; } @@ -119,6 +121,19 @@ interface ActiveMigrations { 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'; } @@ -165,10 +180,10 @@ export class DocumentMigrator implements VersionedTransformer { * @memberof DocumentMigrator */ public get migrationVersion(): SavedObjectsMigrationVersion { - return Object.entries(this.migrations).reduce((acc, [prop, { latestVersion }]) => { - // some migration objects won't have a latestVersion (they only contain reference transforms that are applied from other types) - if (latestVersion) { - return { ...acc, [prop]: 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; }, {}); @@ -292,7 +307,7 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry, kibanaV * 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, @@ -318,12 +333,22 @@ function buildActiveMigrations( 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]: { - latestVersion: _.last(transforms.filter((x) => x.transformType !== 'reference'))?.version, - latestReferenceVersion: _.last(transforms.filter((x) => x.transformType === 'reference')) - ?.version, + latestMigrationVersion: _.last(migrationVersionTransforms)?.version, + latestCoreMigrationVersion: _.last(coreMigrationVersionTransforms)?.version, transforms, }, }; @@ -403,7 +428,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]) ); } @@ -575,7 +600,11 @@ function wrapWithTry( }; } -function getHasPendingReferenceTransform( +/** + * 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 @@ -584,11 +613,11 @@ function getHasPendingReferenceTransform( return false; } - const { latestReferenceVersion } = migrations[prop]; + const { latestCoreMigrationVersion } = migrations[prop]; const { coreMigrationVersion } = doc; return ( - latestReferenceVersion && - (!coreMigrationVersion || Semver.gt(latestReferenceVersion, coreMigrationVersion)) + latestCoreMigrationVersion && + (!coreMigrationVersion || Semver.gt(latestCoreMigrationVersion, coreMigrationVersion)) ); } @@ -597,25 +626,25 @@ function getHasPendingReferenceTransform( */ 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); // 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 ( - (latestVersion && latestVersion !== docVersion) || - getHasPendingReferenceTransform(doc, migrations, p) // If the object itself is up-to-date, check if its references are up-to-date too + (latestMigrationVersion && latestMigrationVersion !== docVersion) || + getHasPendingCoreMigrationVersionTransform(doc, migrations, p) // If the object itself is up-to-date, check if its references are up-to-date too ); }); } @@ -640,14 +669,15 @@ function migrateProp( break; } - if (transformType === 'migrate' || convertNamespaceTypes) { - // migrate transforms are always applied, but conversion transforms and reference transforms are only applied when Kibana is upgraded + 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, increment the version + // 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); From c4866906e7faeea1ee29ab08874eead01fc1b547 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 14 Jan 2021 19:31:02 -0500 Subject: [PATCH 19/22] Update validation in the document migrator Added validation to ensure that documents with a newer coreMigrationVersion will result in an error. Also renamed some existing tests to be more accurate. --- .../migrations/core/document_migrator.test.ts | 39 +++++++++++++++++-- .../migrations/core/document_migrator.ts | 27 +++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 816f307c95041..d5273eff4ce8f 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -368,10 +368,9 @@ describe('DocumentMigrator', () => { }); }); - it('rejects docs that belong to a newer Kibana instance', () => { + it('rejects docs with a migrationVersion[type] for a type that does not have any migrations defined', () => { const migrator = new DocumentMigrator({ ...testOpts(), - kibanaVersion: '8.0.1', }); expect(() => migrator.migrate({ @@ -385,7 +384,7 @@ describe('DocumentMigrator', () => { ); }); - it('rejects docs that belong to a newer plugin', () => { + it('rejects docs with a migrationVersion[type] for a type that does not have a migration >= that version defined', () => { const migrator = new DocumentMigrator({ ...testOpts(), typeRegistry: createRegistry({ @@ -407,6 +406,40 @@ describe('DocumentMigrator', () => { ); }); + it('rejects docs that have an invalid coreMigrationVersion', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + kibanaVersion: '8.0.1', + }); + expect(() => + migrator.migrate({ + id: 'happy', + type: 'dog', + attributes: { name: 'Callie' }, + coreMigrationVersion: 'not-a-semver', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Document \\"happy\\" has an invalid \\"coreMigrationVersion\\" [not-a-semver]. This must be a semver value."` + ); + }); + + it('rejects docs that have a coreMigrationVersion higher than the current Kibana version', () => { + const migrator = new DocumentMigrator({ + ...testOpts(), + kibanaVersion: '8.0.1', + }); + expect(() => + migrator.migrate({ + id: 'wet', + type: 'dog', + attributes: { name: 'Callie' }, + coreMigrationVersion: '8.0.2', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Document \\"wet\\" has a \\"coreMigrationVersion\\" which belongs to a more recent version of Kibana [8.0.2]. The current version is [8.0.1]."` + ); + }); + it('applies migrations in order', () => { let count = 0; const migrator = new DocumentMigrator({ diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 21244a702287f..52ab751a2b568 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -369,6 +369,8 @@ function buildDocumentTransform({ doc: SavedObjectUnsanitizedDoc, options: TransformOptions = {} ) { + validateCoreMigrationVersion(doc, kibanaVersion); + const { convertNamespaceTypes = false } = options; let transformedDoc: SavedObjectUnsanitizedDoc; let additionalDocs: SavedObjectUnsanitizedDoc[] = []; @@ -393,6 +395,31 @@ function buildDocumentTransform({ }; } +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, From 24a90b35abb3159fb4f2f99f11b6df3a6b3341f9 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 15 Jan 2021 15:59:31 -0500 Subject: [PATCH 20/22] Expose `coreMigrationVersion` saved object field to consumers Consumers can now see this field when retrieving existing objects, and they can set it when creating new objects. --- ...public.savedobject.coremigrationversion.md | 13 ++++++ .../kibana-plugin-core-public.savedobject.md | 1 + ...jectscreateoptions.coremigrationversion.md | 13 ++++++ ...n-core-public.savedobjectscreateoptions.md | 1 + ...-public.simplesavedobject._constructor_.md | 4 +- ....simplesavedobject.coremigrationversion.md | 11 +++++ ...na-plugin-core-public.simplesavedobject.md | 3 +- ...server.savedobject.coremigrationversion.md | 13 ++++++ .../kibana-plugin-core-server.savedobject.md | 1 + ...tsbulkcreateobject.coremigrationversion.md | 18 ++++++++ ...ore-server.savedobjectsbulkcreateobject.md | 1 + ...jectscreateoptions.coremigrationversion.md | 18 ++++++++ ...n-core-server.savedobjectscreateoptions.md | 1 + src/core/public/public.api.md | 6 ++- .../saved_objects/saved_objects_client.ts | 2 + .../saved_objects/simple_saved_object.ts | 14 +++++- .../saved_objects/routes/bulk_create.ts | 1 + .../server/saved_objects/routes/create.ts | 18 +++++++- .../service/lib/included_fields.test.ts | 4 +- .../service/lib/included_fields.ts | 1 + .../service/lib/repository.test.js | 6 ++- .../saved_objects/service/lib/repository.ts | 3 +- .../service/saved_objects_client.ts | 20 +++++++++ src/core/server/server.api.md | 3 ++ src/core/types/saved_objects.ts | 2 + .../apis/saved_objects/bulk_create.js | 11 +++++ .../apis/saved_objects/bulk_get.js | 9 ++++ .../apis/saved_objects/create.js | 39 ++++++++++++++++ .../apis/saved_objects/export.ts | 10 +++++ .../apis/saved_objects/find.js | 13 +++++- .../api_integration/apis/saved_objects/get.js | 8 ++++ .../lib/saved_objects_test_utils.ts | 29 ++++++++++++ .../apis/saved_objects/migrations.ts | 44 +++++++++---------- .../apis/saved_objects/resolve.ts | 8 ++++ .../apis/saved_objects_management/find.ts | 10 +++++ 35 files changed, 326 insertions(+), 33 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md create mode 100644 test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md new file mode 100644 index 0000000000000..9060a5d6777fe --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.coremigrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [coreMigrationVersion](./kibana-plugin-core-public.savedobject.coremigrationversion.md) + +## SavedObject.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index eb6059747426d..9404927f94957 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -15,6 +15,7 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-public.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | +| [coreMigrationVersion](./kibana-plugin-core-public.savedobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [error](./kibana-plugin-core-public.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | 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. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md new file mode 100644 index 0000000000000..3c1d068f458bc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-public.savedobjectscreateoptions.md) > [coreMigrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md) + +## SavedObjectsCreateOptions.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md index b1b93407d4ff1..a039b9f5b4fe4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectscreateoptions.md @@ -15,6 +15,7 @@ export interface SavedObjectsCreateOptions | Property | Type | Description | | --- | --- | --- | +| [coreMigrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [id](./kibana-plugin-core-public.savedobjectscreateoptions.id.md) | string | (Not recommended) Specify an id instead of having the saved objects service generate one for you. | | [migrationVersion](./kibana-plugin-core-public.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | 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) | boolean | If a document with the given id already exists, overwrite it's contents (default=false). | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md index b1a4357cca7ad..8fb005421e870 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `SimpleSavedObject` class Signature: ```typescript -constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType); +constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObjectType); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(client: SavedObjectsClientContract, { id, type, version, attributes, | Parameter | Type | Description | | --- | --- | --- | | client | SavedObjectsClientContract | | -| { id, type, version, attributes, error, references, migrationVersion } | SavedObjectType<T> | | +| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, } | SavedObjectType<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md new file mode 100644 index 0000000000000..8e2217fab6eee --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.coremigrationversion.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) > [coreMigrationVersion](./kibana-plugin-core-public.simplesavedobject.coremigrationversion.md) + +## SimpleSavedObject.coreMigrationVersion property + +Signature: + +```typescript +coreMigrationVersion: SavedObjectType['coreMigrationVersion']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md index e9987f6d5bebb..35264a3a4cf0c 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md @@ -18,7 +18,7 @@ export declare class SimpleSavedObject | 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 SimpleSavedObject class | +| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | ## Properties @@ -26,6 +26,7 @@ export declare class SimpleSavedObject | --- | --- | --- | --- | | [\_version](./kibana-plugin-core-public.simplesavedobject._version.md) | | SavedObjectType<T>['version'] | | | [attributes](./kibana-plugin-core-public.simplesavedobject.attributes.md) | | T | | +| [coreMigrationVersion](./kibana-plugin-core-public.simplesavedobject.coremigrationversion.md) | | SavedObjectType<T>['coreMigrationVersion'] | | | [error](./kibana-plugin-core-public.simplesavedobject.error.md) | | SavedObjectType<T>['error'] | | | [id](./kibana-plugin-core-public.simplesavedobject.id.md) | | SavedObjectType<T>['id'] | | | [migrationVersion](./kibana-plugin-core-public.simplesavedobject.migrationversion.md) | | SavedObjectType<T>['migrationVersion'] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md new file mode 100644 index 0000000000000..b4d1f3c769451 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.coremigrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [coreMigrationVersion](./kibana-plugin-core-server.savedobject.coremigrationversion.md) + +## SavedObject.coreMigrationVersion property + +A semver value that is used when upgrading objects between Kibana versions. + +Signature: + +```typescript +coreMigrationVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 5aefc55736cd1..07172487e6fde 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -15,6 +15,7 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | +| [coreMigrationVersion](./kibana-plugin-core-server.savedobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [error](./kibana-plugin-core-server.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | 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. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md new file mode 100644 index 0000000000000..fb1f485cdf202 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [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. + +Signature: + +```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. + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 5ac5f6d9807bd..6fc01212a2e41 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -16,6 +16,7 @@ export interface SavedObjectsBulkCreateObject | Property | Type | Description | | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | +| [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) | string | 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) | string | | | [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | 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) | SavedObjectsMigrationVersion | 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. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md new file mode 100644 index 0000000000000..e2a4064ec4f33 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [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. + +Signature: + +```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. + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index e6d306784f8ae..1805f389d4e7f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -15,6 +15,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | +| [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) | string | 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) | string | (not recommended) Specify an id for the document | | [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | 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) | SavedObjectsMigrationVersion | 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. | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 2f4c871c33431..36dd360a6b439 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1080,6 +1080,7 @@ export type PublicUiSettingsParams = Omit; // @public (undocumented) export interface SavedObject { attributes: T; + coreMigrationVersion?: string; // (undocumented) error?: SavedObjectError; id: string; @@ -1197,6 +1198,7 @@ export type SavedObjectsClientContract = PublicMethodsOf; // @public (undocumented) export interface SavedObjectsCreateOptions { + coreMigrationVersion?: string; id?: string; migrationVersion?: SavedObjectsMigrationVersion; overwrite?: boolean; @@ -1409,10 +1411,12 @@ export class ScopedHistory implements History { - constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObject); + constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObject); // (undocumented) attributes: T; // (undocumented) + coreMigrationVersion: SavedObject['coreMigrationVersion']; + // (undocumented) delete(): Promise<{}>; // (undocumented) error: SavedObject['error']; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index d8b65dbc2330e..94f057f815439 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -49,6 +49,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[]; } diff --git a/src/core/public/saved_objects/simple_saved_object.ts b/src/core/public/saved_objects/simple_saved_object.ts index 5bd339fbd7c96..fa11cfcdc622d 100644 --- a/src/core/public/saved_objects/simple_saved_object.ts +++ b/src/core/public/saved_objects/simple_saved_object.ts @@ -38,12 +38,22 @@ export class SimpleSavedObject { public id: SavedObjectType['id']; public type: SavedObjectType['type']; public migrationVersion: SavedObjectType['migrationVersion']; + public coreMigrationVersion: SavedObjectType['coreMigrationVersion']; public error: SavedObjectType['error']; public references: SavedObjectType['references']; constructor( private client: SavedObjectsClientContract, - { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType + { + id, + type, + version, + attributes, + error, + references, + migrationVersion, + coreMigrationVersion, + }: SavedObjectType ) { this.id = id; this.type = type; @@ -51,6 +61,7 @@ export class SimpleSavedObject { this.references = references || []; this._version = version; this.migrationVersion = migrationVersion; + this.coreMigrationVersion = coreMigrationVersion; if (error) { this.error = error; } @@ -77,6 +88,7 @@ export class SimpleSavedObject { } else { return this.client.create(this.type, this.attributes, { migrationVersion: this.migrationVersion, + coreMigrationVersion: this.coreMigrationVersion, references: this.references, }); } diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index b1286f3a1f06c..491c54c406972 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -40,6 +40,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({ diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index cb6a849be9f2d..a223f6e0fedd9 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -40,6 +40,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({ @@ -56,12 +57,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 }); }) diff --git a/src/core/server/saved_objects/service/lib/included_fields.test.ts b/src/core/server/saved_objects/service/lib/included_fields.test.ts index 356ffff398343..5338fb7845532 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.test.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.test.ts @@ -19,7 +19,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', () => { @@ -43,6 +43,7 @@ Array [ "type", "references", "migrationVersion", + "coreMigrationVersion", "updated_at", "originId", "foo", @@ -77,6 +78,7 @@ Array [ "type", "references", "migrationVersion", + "coreMigrationVersion", "updated_at", "originId", "foo", diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index 63d8f184ed2f2..54fa8147e94d2 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -41,6 +41,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 diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index b8f3c4a46493f..bdc5be4d51c1a 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -56,6 +56,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'; @@ -154,7 +155,7 @@ describe('SavedObjectsRepository', () => { const documentMigrator = new DocumentMigrator({ typeRegistry: registry, - kibanaVersion: '2.0.0', + kibanaVersion: KIBANA_VERSION, log: {}, }); @@ -513,6 +514,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, @@ -2154,6 +2156,7 @@ describe('SavedObjectsRepository', () => { references, namespaces: [namespace ?? 'default'], migrationVersion: { [type]: '1.1.1' }, + coreMigrationVersion: KIBANA_VERSION, }); }); }); @@ -2738,6 +2741,7 @@ describe('SavedObjectsRepository', () => { 'type', 'references', 'migrationVersion', + 'coreMigrationVersion', 'updated_at', 'originId', 'title', diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index f1b777a793ea6..151d321f7d2bb 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1809,7 +1809,7 @@ export class SavedObjectsRepository { if (this._registry.isSingleNamespace(type)) { savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)]; } - return omit(savedObject, ['namespace', 'coreMigrationVersion']) as SavedObject; + return omit(savedObject, ['namespace']) as SavedObject; } /** @@ -1930,6 +1930,7 @@ export class SavedObjectsRepository { attributes: doc._source[type], references: doc._source.references || [], migrationVersion: doc._source.migrationVersion, + coreMigrationVersion: doc._source.coreMigrationVersion, }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 4c59ae25925c5..d37c4b6db3852 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -45,6 +45,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; @@ -71,6 +81,16 @@ export interface SavedObjectsBulkCreateObject { 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; /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 622f3f7b54730..9c13d5489baec 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2047,6 +2047,7 @@ export type SafeRouteMethod = 'get' | 'options'; // @public (undocumented) export interface SavedObject { attributes: T; + coreMigrationVersion?: string; // Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -2130,6 +2131,7 @@ export interface SavedObjectsBaseOptions { export interface SavedObjectsBulkCreateObject { // (undocumented) attributes: T; + coreMigrationVersion?: string; // (undocumented) id?: string; initialNamespaces?: string[]; @@ -2291,6 +2293,7 @@ export interface SavedObjectsCoreFieldMapping { // @public (undocumented) export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { + coreMigrationVersion?: string; id?: string; initialNamespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 9abc093c74fb3..6c561b5dd4446 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -93,6 +93,8 @@ export interface SavedObject { 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[]; /** diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js index a78acea1d0299..2b611c951b8e2 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.js +++ b/test/api_integration/apis/saved_objects/bulk_create.js @@ -18,6 +18,7 @@ */ import expect from '@kbn/expect'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }) { const supertest = getService('supertest'); @@ -31,6 +32,7 @@ export default function ({ getService }) { attributes: { title: 'An existing visualization', }, + coreMigrationVersion: '1.2.3', }, { type: 'dashboard', @@ -42,6 +44,12 @@ export default function ({ getService }) { ]; describe('_bulk_create', () => { + let KIBANA_VERSION; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -75,6 +83,7 @@ export default function ({ getService }) { migrationVersion: { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, + coreMigrationVersion: KIBANA_VERSION, references: [], namespaces: ['default'], }, @@ -126,6 +135,7 @@ export default function ({ getService }) { migrationVersion: { visualization: resp.body.saved_objects[0].migrationVersion.visualization, }, + coreMigrationVersion: KIBANA_VERSION, // updated from 1.2.3 to the latest kibana version }, { type: 'dashboard', @@ -140,6 +150,7 @@ export default function ({ getService }) { migrationVersion: { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, + coreMigrationVersion: KIBANA_VERSION, }, ], }); diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js index 56ee5a69be23e..447b40a16ae1d 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.js +++ b/test/api_integration/apis/saved_objects/bulk_get.js @@ -18,6 +18,7 @@ */ import expect from '@kbn/expect'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }) { const supertest = getService('supertest'); @@ -40,6 +41,12 @@ export default function ({ getService }) { ]; describe('_bulk_get', () => { + let KIBANA_VERSION; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -68,6 +75,7 @@ export default function ({ getService }) { resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], references: [ { @@ -97,6 +105,7 @@ export default function ({ getService }) { }, namespaces: ['default'], migrationVersion: resp.body.saved_objects[2].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, references: [], }, ], diff --git a/test/api_integration/apis/saved_objects/create.js b/test/api_integration/apis/saved_objects/create.js index 15aecb6e547a0..9c684e6217b9c 100644 --- a/test/api_integration/apis/saved_objects/create.js +++ b/test/api_integration/apis/saved_objects/create.js @@ -18,6 +18,7 @@ */ import expect from '@kbn/expect'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }) { const supertest = getService('supertest'); @@ -25,6 +26,12 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('create', () => { + let KIBANA_VERSION; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -52,6 +59,7 @@ export default function ({ getService }) { id: resp.body.id, type: 'visualization', migrationVersion: resp.body.migrationVersion, + coreMigrationVersion: KIBANA_VERSION, updated_at: resp.body.updated_at, version: resp.body.version, attributes: { @@ -63,6 +71,21 @@ export default function ({ getService }) { 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', () => { @@ -99,6 +122,7 @@ export default function ({ getService }) { id: resp.body.id, type: 'visualization', migrationVersion: resp.body.migrationVersion, + coreMigrationVersion: KIBANA_VERSION, updated_at: resp.body.updated_at, version: resp.body.version, attributes: { @@ -112,6 +136,21 @@ export default function ({ getService }) { expect(await es.indices.exists({ index: '.kibana' })).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); + }); + }); }); }); } diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index 43bf37275c00f..8027cc126499d 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -19,6 +19,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)); @@ -29,6 +30,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')); @@ -323,6 +330,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', @@ -382,6 +390,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', @@ -446,6 +455,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', diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 208343a04f0f2..e0a157ebf4ed8 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -18,6 +18,7 @@ */ import expect from '@kbn/expect'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }) { const supertest = getService('supertest'); @@ -25,6 +26,12 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('find', () => { + let KIBANA_VERSION; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -48,6 +55,7 @@ export default function ({ getService }) { }, score: 0, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], references: [ { @@ -143,6 +151,7 @@ export default function ({ getService }) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], score: 0, references: [ @@ -179,6 +188,7 @@ export default function ({ getService }) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], score: 0, references: [ @@ -196,6 +206,7 @@ export default function ({ getService }) { }, id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['foo-ns'], references: [ { @@ -211,7 +222,6 @@ export default function ({ getService }) { }, ], }); - expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); })); }); @@ -253,6 +263,7 @@ export default function ({ getService }) { }, ], migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, updated_at: '2017-09-21T18:51:23.794Z', version: 'WzIsMV0=', }, diff --git a/test/api_integration/apis/saved_objects/get.js b/test/api_integration/apis/saved_objects/get.js index 6bb5cf0c8a7ff..a85389ca358a4 100644 --- a/test/api_integration/apis/saved_objects/get.js +++ b/test/api_integration/apis/saved_objects/get.js @@ -18,6 +18,7 @@ */ import expect from '@kbn/expect'; +import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }) { const supertest = getService('supertest'); @@ -25,6 +26,12 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('get', () => { + let KIBANA_VERSION; + + before(async () => { + KIBANA_VERSION = await getKibanaVersion(getService); + }); + describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -40,6 +47,7 @@ export default function ({ getService }) { 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: '', diff --git a/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts b/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts new file mode 100644 index 0000000000000..b3cdb67e67376 --- /dev/null +++ b/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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; +} diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 2c051ff709375..59d4a28aed607 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -177,7 +177,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'bar:o', @@ -185,14 +185,14 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:a', @@ -200,7 +200,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:e', @@ -208,7 +208,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, ]); }); @@ -276,7 +276,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'bar:o', @@ -284,7 +284,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:a', @@ -292,7 +292,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:e', @@ -300,7 +300,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, ]); @@ -312,7 +312,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 68, name: 'NAME i' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'bar:o', @@ -320,7 +320,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { bar: '2.3.4' }, bar: { mynum: 6, name: 'NAME o' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:a', @@ -328,7 +328,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'foo:e', @@ -336,7 +336,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, ]); }); @@ -398,7 +398,7 @@ export default ({ getService }: FtrProviderContext) => { migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' }, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, ]); }); @@ -487,7 +487,7 @@ export default ({ getService }: FtrProviderContext) => { references: [], namespaces: ['default'], migrationVersion: { foo: '1.0.0' }, - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: `foo:${newFooId}`, @@ -497,7 +497,7 @@ export default ({ getService }: FtrProviderContext) => { namespaces: ['spacex'], originId: '1', migrationVersion: { foo: '1.0.0' }, - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { // new object @@ -510,7 +510,7 @@ export default ({ getService }: FtrProviderContext) => { }, migrationVersion: {}, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'bar:1', @@ -519,7 +519,7 @@ export default ({ getService }: FtrProviderContext) => { references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], namespaces: ['default'], migrationVersion: { bar: '2.0.0' }, - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: `bar:${newBarId}`, @@ -529,7 +529,7 @@ export default ({ getService }: FtrProviderContext) => { namespaces: ['spacex'], originId: '1', migrationVersion: { bar: '2.0.0' }, - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { // new object @@ -542,14 +542,14 @@ export default ({ getService }: FtrProviderContext) => { }, migrationVersion: {}, references: [], - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + 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, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, { id: 'spacex:baz:1', @@ -557,7 +557,7 @@ export default ({ getService }: FtrProviderContext) => { baz: { title: 'Baz 1 spacex' }, references: [{ type: 'bar', id: newBarId, name: 'Bar 1 spacex' }], namespace: 'spacex', - coreMigrationVersion: KIBANA_VERSION, // this field is omitted from saved objects, but we fetched the raw document here + coreMigrationVersion: KIBANA_VERSION, }, ].sort(sortByTypeAndId) ); diff --git a/test/api_integration/apis/saved_objects/resolve.ts b/test/api_integration/apis/saved_objects/resolve.ts index 21e27a57715d1..cbcc3b39c94e4 100644 --- a/test/api_integration/apis/saved_objects/resolve.ts +++ b/test/api_integration/apis/saved_objects/resolve.ts @@ -19,6 +19,7 @@ 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'); @@ -26,6 +27,12 @@ export default function ({ getService }: FtrProviderContext) { 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')); @@ -42,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) { 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: '', diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 6413f5e9226ee..b281d43b5840a 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -25,8 +25,17 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('legacyEs'); 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')); @@ -49,6 +58,7 @@ export default function ({ getService }: FtrProviderContext) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, namespaces: ['default'], references: [ { From 7796bc194efdcad48960381dce89ee1b2f102496 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 19 Jan 2021 11:52:20 -0500 Subject: [PATCH 21/22] Add minimum version for saved object namespace type conversion --- .../migrations/core/document_migrator.test.ts | 21 +++++++++++++++++ .../migrations/core/document_migrator.ts | 23 ++++++++++++++++--- .../apis/saved_objects/migrations.ts | 1 + 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index d5273eff4ce8f..d7ff1b36764b9 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -55,6 +55,7 @@ describe('DocumentMigrator', () => { return { kibanaVersion, typeRegistry: createRegistry(), + minimumConvertVersion: '0.0.0', // no minimum version unless we specify it for a test case log: mockLogger, }; } @@ -129,6 +130,7 @@ describe('DocumentMigrator', () => { name: 'foo', convertToMultiNamespaceTypeVersion: 'bar', }), + minimumConvertVersion: '0.0.0', log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrow( @@ -144,6 +146,7 @@ describe('DocumentMigrator', () => { convertToMultiNamespaceTypeVersion: 'bar', namespaceType: 'multiple', }), + minimumConvertVersion: '0.0.0', log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrow( @@ -151,6 +154,22 @@ describe('DocumentMigrator', () => { ); }); + it('validates convertToMultiNamespaceTypeVersion is not less than the minimum allowed version', () => { + const invalidDefinition = { + kibanaVersion: '3.2.3', + typeRegistry: createRegistry({ + name: 'foo', + convertToMultiNamespaceTypeVersion: '3.2.4', + namespaceType: 'multiple', + }), + // not using a minimumConvertVersion parameter, the default is 8.0.0 + log: mockLogger, + }; + expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( + `Invalid convertToMultiNamespaceTypeVersion for type foo. Value '3.2.4' cannot be less than '8.0.0'.` + ); + }); + it('validates convertToMultiNamespaceTypeVersion is not greater than the current Kibana version', () => { const invalidDefinition = { kibanaVersion: '3.2.3', @@ -159,6 +178,7 @@ describe('DocumentMigrator', () => { convertToMultiNamespaceTypeVersion: '3.2.4', namespaceType: 'multiple', }), + minimumConvertVersion: '0.0.0', log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( @@ -174,6 +194,7 @@ describe('DocumentMigrator', () => { convertToMultiNamespaceTypeVersion: '3.1.1', namespaceType: 'multiple', }), + minimumConvertVersion: '0.0.0', log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrowError( diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 52ab751a2b568..9d5cd751e0d85 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -78,6 +78,8 @@ import { SavedObjectMigrationFn } from '../types'; import { DEFAULT_NAMESPACE_STRING } from '../../service/lib/utils'; import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; +const DEFAULT_MINIMUM_CONVERT_VERSION = '8.0.0'; + export type MigrateFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc; export type MigrateAndConvertFn = (doc: SavedObjectUnsanitizedDoc) => SavedObjectUnsanitizedDoc[]; @@ -105,6 +107,7 @@ interface TransformOptions { interface DocumentMigratorOptions { kibanaVersion: string; typeRegistry: ISavedObjectTypeRegistry; + minimumConvertVersion?: string; log: Logger; } @@ -159,11 +162,17 @@ 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, kibanaVersion); + constructor({ + typeRegistry, + kibanaVersion, + minimumConvertVersion = DEFAULT_MINIMUM_CONVERT_VERSION, + log, + }: DocumentMigratorOptions) { + validateMigrationDefinition(typeRegistry, kibanaVersion, minimumConvertVersion); this.migrations = buildActiveMigrations(typeRegistry, log); this.transformDoc = buildDocumentTransform({ @@ -231,7 +240,11 @@ export class DocumentMigrator implements VersionedTransformer { * 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, kibanaVersion: string) { +function validateMigrationDefinition( + registry: ISavedObjectTypeRegistry, + kibanaVersion: string, + minimumConvertVersion: string +) { function assertObject(obj: any, prefix: string) { if (!obj || typeof obj !== 'object') { throw new Error(`${prefix} Got ${obj}.`); @@ -270,6 +283,10 @@ function validateMigrationDefinition(registry: ISavedObjectTypeRegistry, kibanaV 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}'.` diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 59d4a28aed607..5bc9a97466dbc 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -641,6 +641,7 @@ async function migrateIndex({ const documentMigrator = new DocumentMigrator({ 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(), }); From 717efcfee393e9d5497e305eff4307ea5df5f156 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 19 Jan 2021 21:40:03 -0500 Subject: [PATCH 22/22] Update license headers --- .../migrations/core/__mocks__/index.ts | 21 +++++-------------- .../saved_objects/object_types/constants.ts | 21 +++++-------------- .../saved_objects/object_types/index.ts | 21 +++++-------------- .../object_types/registration.test.ts | 21 +++++-------------- .../object_types/registration.ts | 21 +++++-------------- .../saved_objects/object_types/types.ts | 21 +++++-------------- .../routes/integration_tests/resolve.test.ts | 21 +++++-------------- .../server/saved_objects/routes/resolve.ts | 21 +++++-------------- .../lib/saved_objects_test_utils.ts | 21 +++++-------------- .../apis/saved_objects/resolve.ts | 21 +++++-------------- 10 files changed, 50 insertions(+), 160 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/__mocks__/index.ts b/src/core/server/saved_objects/migrations/core/__mocks__/index.ts index 4dbc29084ad7b..b22ad0c93b234 100644 --- a/src/core/server/saved_objects/migrations/core/__mocks__/index.ts +++ b/src/core/server/saved_objects/migrations/core/__mocks__/index.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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'); diff --git a/src/core/server/saved_objects/object_types/constants.ts b/src/core/server/saved_objects/object_types/constants.ts index 246b38ee8e10a..4e05c406c653f 100644 --- a/src/core/server/saved_objects/object_types/constants.ts +++ b/src/core/server/saved_objects/object_types/constants.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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. */ /** diff --git a/src/core/server/saved_objects/object_types/index.ts b/src/core/server/saved_objects/object_types/index.ts index d9a3e6252f4ef..1a9bccdc17c28 100644 --- a/src/core/server/saved_objects/object_types/index.ts +++ b/src/core/server/saved_objects/object_types/index.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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'; diff --git a/src/core/server/saved_objects/object_types/registration.test.ts b/src/core/server/saved_objects/object_types/registration.test.ts index 6a5daf1a357d5..9bd7b3d61e099 100644 --- a/src/core/server/saved_objects/object_types/registration.test.ts +++ b/src/core/server/saved_objects/object_types/registration.test.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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'; diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts index 7cf43d1fb95d2..82562ac53a109 100644 --- a/src/core/server/saved_objects/object_types/registration.ts +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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'; diff --git a/src/core/server/saved_objects/object_types/types.ts b/src/core/server/saved_objects/object_types/types.ts index 8ba54168cac78..8391311cbefdf 100644 --- a/src/core/server/saved_objects/object_types/types.ts +++ b/src/core/server/saved_objects/object_types/types.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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. */ /** diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts index 440805fdea7f3..5ddeb29b8c2d5 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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'; diff --git a/src/core/server/saved_objects/routes/resolve.ts b/src/core/server/saved_objects/routes/resolve.ts index afaecef7b2d0c..28a3f4b876467 100644 --- a/src/core/server/saved_objects/routes/resolve.ts +++ b/src/core/server/saved_objects/routes/resolve.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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'; diff --git a/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts b/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts index b3cdb67e67376..e278bd3d50034 100644 --- a/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts +++ b/test/api_integration/apis/saved_objects/lib/saved_objects_test_utils.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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'; diff --git a/test/api_integration/apis/saved_objects/resolve.ts b/test/api_integration/apis/saved_objects/resolve.ts index cbcc3b39c94e4..b71d5e3003495 100644 --- a/test/api_integration/apis/saved_objects/resolve.ts +++ b/test/api_integration/apis/saved_objects/resolve.ts @@ -1,20 +1,9 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * 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';