Preserve originId when overwriting existing objects (#135358)

This commit is contained in:
Joe Portner 2022-06-28 22:56:32 -04:00 committed by GitHub
parent 592e06e5e8
commit ff57626fec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 198 additions and 36 deletions

View file

@ -56,8 +56,8 @@ export const createSavedObjects = async <T>({
new Map<string, SavedObject<T>>() new Map<string, SavedObject<T>>()
); );
// filter out the 'version' field of each object, if it exists // filter out the 'version' field of each object, if it exists, and set the originId appropriately
const objectsToCreate = filteredObjects.map(({ version, ...object }) => { const objectsToCreate = filteredObjects.map(({ version, originId, ...object }) => {
// use the import ID map to ensure that each reference is being created with the correct ID // use the import ID map to ensure that each reference is being created with the correct ID
const references = object.references?.map((reference) => { const references = object.references?.map((reference) => {
const { type, id } = reference; const { type, id } = reference;
@ -72,15 +72,18 @@ export const createSavedObjects = async <T>({
const importStateValue = importStateMap.get(`${object.type}:${object.id}`); const importStateValue = importStateMap.get(`${object.type}:${object.id}`);
if (importStateValue?.destinationId) { if (importStateValue?.destinationId) {
objectIdMap.set(`${object.type}:${importStateValue.destinationId}`, object); objectIdMap.set(`${object.type}:${importStateValue.destinationId}`, object);
const originId = importStateValue.omitOriginId ? undefined : object.originId ?? object.id;
return { return {
...object, ...object,
id: importStateValue.destinationId, id: importStateValue.destinationId,
originId,
...(references && { references }), ...(references && { references }),
// Do not set originId, not even to undefined, if omitOriginId is true.
// When omitOriginId is true, we are trying to create a brand new object without setting the originId at all.
// Semantically, setting `originId: undefined` is used to clear out an existing object's originId when overwriting it
// (and if you attempt to do that on a non-multi-namespace object type, it will result in a 400 Bad Request error).
...(!importStateValue.omitOriginId && { originId: originId ?? object.id }),
}; };
} }
return { ...object, ...(references && { references }) }; return { ...object, ...(references && { references }), ...(originId && { originId }) };
}); });
const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references']; const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references'];

View file

@ -544,13 +544,11 @@ describe(`POST ${URL}`, () => {
type: 'visualization', type: 'visualization',
id: 'new-id-1', id: 'new-id-1',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }], references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }],
originId: undefined,
}), }),
expect.objectContaining({ expect.objectContaining({
type: 'dashboard', type: 'dashboard',
id: 'new-id-2', id: 'new-id-2',
references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }], references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }],
originId: undefined,
}), }),
], ],
expect.any(Object) // options expect.any(Object) // options

View file

@ -396,13 +396,11 @@ describe(`POST ${URL}`, () => {
type: 'visualization', type: 'visualization',
id: 'new-id-1', id: 'new-id-1',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'existing' }], references: [{ name: 'ref_0', type: 'index-pattern', id: 'existing' }],
originId: undefined,
}), }),
expect.objectContaining({ expect.objectContaining({
type: 'dashboard', type: 'dashboard',
id: 'new-id-2', id: 'new-id-2',
references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }], references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }],
originId: undefined,
}), }),
], ],
expect.any(Object) // options expect.any(Object) // options

View file

@ -255,7 +255,7 @@ async function bulkGetObjectsAndAliases(
docsToBulkGet.push({ docsToBulkGet.push({
_id: serializer.generateRawId(undefined, type, id), // namespace is intentionally undefined because multi-namespace objects don't have a namespace in their raw ID _id: serializer.generateRawId(undefined, type, id), // namespace is intentionally undefined because multi-namespace objects don't have a namespace in their raw ID
_index: getIndexForType(type), _index: getIndexForType(type),
_source: ['type', 'namespaces'], _source: ['type', 'namespaces', 'originId'],
}); });
if (checkAliases) { if (checkAliases) {
for (const space of spaces) { for (const space of spaces) {

View file

@ -46,6 +46,7 @@ import {
import { ALL_NAMESPACES_STRING } from './utils'; import { ALL_NAMESPACES_STRING } from './utils';
import { loggerMock } from '@kbn/logging-mocks'; import { loggerMock } from '@kbn/logging-mocks';
import { import {
SavedObjectsRawDoc,
SavedObjectsRawDocSource, SavedObjectsRawDocSource,
SavedObjectsSerializer, SavedObjectsSerializer,
SavedObjectUnsanitizedDoc, SavedObjectUnsanitizedDoc,
@ -431,7 +432,6 @@ describe('SavedObjectsRepository', () => {
id: '6.0.0-alpha1', id: '6.0.0-alpha1',
attributes: { title: 'Test One' }, attributes: { title: 'Test One' },
references: [{ name: 'ref_0', type: 'test', id: '1' }], references: [{ name: 'ref_0', type: 'test', id: '1' }],
originId: 'some-origin-id', // only one of the object args has an originId, this is intentional to test both a positive and negative case
}; };
const obj2 = { const obj2 = {
type: 'index-pattern', type: 'index-pattern',
@ -613,6 +613,96 @@ describe('SavedObjectsRepository', () => {
); );
}); });
describe('originId', () => {
it(`returns error if originId is set for non-multi-namespace type`, async () => {
const result = await savedObjectsRepository.bulkCreate([
{ ...obj1, originId: 'some-originId' },
{ ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' },
]);
expect(result.saved_objects).toEqual([
expect.objectContaining({ id: obj1.id, type: obj1.type, error: expect.anything() }),
expect.objectContaining({
id: obj2.id,
type: NAMESPACE_AGNOSTIC_TYPE,
error: expect.anything(),
}),
]);
expect(client.bulk).not.toHaveBeenCalled();
});
it(`defaults to no originId`, async () => {
const objects = [
{ ...obj1, type: MULTI_NAMESPACE_TYPE },
{ ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE },
];
await bulkCreateSuccess(objects);
const expected = expect.not.objectContaining({ originId: expect.anything() });
const body = [expect.any(Object), expected, expect.any(Object), expected];
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
});
describe('with existing originId', () => {
beforeEach(() => {
mockPreflightCheckForCreate.mockImplementation(({ objects }) => {
const existingDocument = {
_source: { originId: 'existing-originId' },
} as SavedObjectsRawDoc;
return Promise.resolve(
objects.map(({ type, id }) => ({ type, id, existingDocument }))
);
});
});
it(`accepts custom originId for multi-namespace type`, async () => {
// The preflight result has `existing-originId`, but that is discarded
const objects = [
{ ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' },
{ ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' },
];
await bulkCreateSuccess(objects);
const expected = expect.objectContaining({ originId: 'some-originId' });
const body = [expect.any(Object), expected, expect.any(Object), expected];
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
});
it(`accepts undefined originId`, async () => {
// The preflight result has `existing-originId`, but that is discarded
const objects = [
{ ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined },
{ ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined },
];
await bulkCreateSuccess(objects);
const expected = expect.not.objectContaining({ originId: expect.anything() });
const body = [expect.any(Object), expected, expect.any(Object), expected];
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
});
it(`preserves existing originId if originId option is not set`, async () => {
const objects = [
{ ...obj1, type: MULTI_NAMESPACE_TYPE },
{ ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE },
];
await bulkCreateSuccess(objects);
const expected = expect.objectContaining({ originId: 'existing-originId' });
const body = [expect.any(Object), expected, expect.any(Object), expected];
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
});
});
});
it(`adds namespace to request body for any types that are single-namespace`, async () => { it(`adds namespace to request body for any types that are single-namespace`, async () => {
await bulkCreateSuccess([obj1, obj2], { namespace }); await bulkCreateSuccess([obj1, obj2], { namespace });
const expected = expect.objectContaining({ namespace }); const expected = expect.objectContaining({ namespace });
@ -2116,7 +2206,6 @@ describe('SavedObjectsRepository', () => {
const attributes = { title: 'Logstash' }; const attributes = { title: 'Logstash' };
const id = 'logstash-*'; const id = 'logstash-*';
const namespace = 'foo-namespace'; const namespace = 'foo-namespace';
const originId = 'some-origin-id';
const references = [ const references = [
{ {
name: 'ref_0', name: 'ref_0',
@ -2226,24 +2315,73 @@ describe('SavedObjectsRepository', () => {
await test(null); await test(null);
}); });
it(`defaults to no originId`, async () => { describe('originId', () => {
await createSuccess(type, attributes, { id }); for (const objType of [type, NAMESPACE_AGNOSTIC_TYPE]) {
expect(client.create).toHaveBeenCalledWith( it(`throws an error if originId is set for non-multi-namespace type`, async () => {
expect.objectContaining({ await expect(
body: expect.not.objectContaining({ originId: expect.anything() }), savedObjectsRepository.create(objType, attributes, { originId: 'some-originId' })
}), ).rejects.toThrowError(
expect.anything() createBadRequestError('"originId" can only be set for multi-namespace object types')
); );
}); });
}
it(`accepts custom originId`, async () => { for (const objType of [MULTI_NAMESPACE_TYPE, MULTI_NAMESPACE_ISOLATED_TYPE]) {
await createSuccess(type, attributes, { id, originId }); it(`${objType} defaults to no originId`, async () => {
expect(client.create).toHaveBeenCalledWith( await createSuccess(objType, attributes, { id });
expect.objectContaining({ expect(client.create).toHaveBeenCalledWith(
body: expect.objectContaining({ originId }), expect.objectContaining({
}), body: expect.not.objectContaining({ originId: expect.anything() }),
expect.anything() }),
); expect.anything()
);
});
describe(`${objType} with existing originId`, () => {
beforeEach(() => {
mockPreflightCheckForCreate.mockImplementation(({ objects }) => {
const existingDocument = {
_source: { originId: 'existing-originId' },
} as SavedObjectsRawDoc;
return Promise.resolve(
objects.map(({ type, id }) => ({ type, id, existingDocument }))
);
});
});
it(`accepts custom originId for multi-namespace type`, async () => {
// The preflight result has `existing-originId`, but that is discarded
await createSuccess(objType, attributes, { id, originId: 'some-originId' });
expect(client.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ originId: 'some-originId' }),
}),
expect.anything()
);
});
it(`accepts undefined originId`, async () => {
// The preflight result has `existing-originId`, but that is discarded
await createSuccess(objType, attributes, { id, originId: undefined });
expect(client.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.not.objectContaining({ originId: expect.anything() }),
}),
expect.anything()
);
});
it(`preserves existing originId if originId option is not set`, async () => {
await createSuccess(objType, attributes, { id });
expect(client.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ originId: 'existing-originId' }),
}),
expect.anything()
);
});
});
}
}); });
it(`defaults to a refresh setting of wait_for`, async () => { it(`defaults to a refresh setting of wait_for`, async () => {
@ -2638,22 +2776,20 @@ describe('SavedObjectsRepository', () => {
describe('returns', () => { describe('returns', () => {
it(`formats the ES response`, async () => { it(`formats the ES response`, async () => {
const result = await createSuccess(type, attributes, { const result = await createSuccess(MULTI_NAMESPACE_TYPE, attributes, {
id, id,
namespace, namespace,
references, references,
originId,
}); });
expect(result).toEqual({ expect(result).toEqual({
type, type: MULTI_NAMESPACE_TYPE,
id, id,
originId,
...mockTimestampFields, ...mockTimestampFields,
version: mockVersion, version: mockVersion,
attributes, attributes,
references, references,
namespaces: [namespace ?? 'default'], namespaces: [namespace ?? 'default'],
migrationVersion: { [type]: '1.1.1' }, migrationVersion: { [MULTI_NAMESPACE_TYPE]: '1.1.1' },
coreMigrationVersion: KIBANA_VERSION, coreMigrationVersion: KIBANA_VERSION,
}); });
}); });

View file

@ -315,7 +315,6 @@ export class SavedObjectsRepository {
overwrite = false, overwrite = false,
references = [], references = [],
refresh = DEFAULT_REFRESH_SETTING, refresh = DEFAULT_REFRESH_SETTING,
originId,
initialNamespaces, initialNamespaces,
version, version,
} = options; } = options;
@ -323,6 +322,7 @@ export class SavedObjectsRepository {
const namespace = normalizeNamespace(options.namespace); const namespace = normalizeNamespace(options.namespace);
this.validateInitialNamespaces(type, initialNamespaces); this.validateInitialNamespaces(type, initialNamespaces);
this.validateOriginId(type, options);
if (!this._allowedTypes.includes(type)) { if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
@ -331,6 +331,7 @@ export class SavedObjectsRepository {
const time = getCurrentTime(); const time = getCurrentTime();
let savedObjectNamespace: string | undefined; let savedObjectNamespace: string | undefined;
let savedObjectNamespaces: string[] | undefined; let savedObjectNamespaces: string[] | undefined;
let existingOriginId: string | undefined;
if (this._registry.isSingleNamespace(type)) { if (this._registry.isSingleNamespace(type)) {
savedObjectNamespace = initialNamespaces savedObjectNamespace = initialNamespaces
@ -354,11 +355,17 @@ export class SavedObjectsRepository {
} }
savedObjectNamespaces = savedObjectNamespaces =
initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument);
existingOriginId = existingDocument?._source?.originId;
} else { } else {
savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace);
} }
} }
// 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that.
// 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any.
const originId = Object.keys(options).includes('originId')
? options.originId
: existingOriginId;
const migrated = this._migrator.migrateDocument({ const migrated = this._migrator.migrateDocument({
id, id,
type, type,
@ -442,6 +449,7 @@ export class SavedObjectsRepository {
} else { } else {
try { try {
this.validateInitialNamespaces(type, initialNamespaces); this.validateInitialNamespaces(type, initialNamespaces);
this.validateOriginId(type, object);
} catch (e) { } catch (e) {
error = e; error = e;
} }
@ -499,6 +507,7 @@ export class SavedObjectsRepository {
let savedObjectNamespace: string | undefined; let savedObjectNamespace: string | undefined;
let savedObjectNamespaces: string[] | undefined; let savedObjectNamespaces: string[] | undefined;
let existingOriginId: string | undefined;
let versionProperties; let versionProperties;
const { const {
preflightCheckIndex, preflightCheckIndex,
@ -525,6 +534,7 @@ export class SavedObjectsRepository {
savedObjectNamespaces = savedObjectNamespaces =
initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument);
versionProperties = getExpectedVersionProperties(version); versionProperties = getExpectedVersionProperties(version);
existingOriginId = existingDocument?._source?.originId;
} else { } else {
if (this._registry.isSingleNamespace(object.type)) { if (this._registry.isSingleNamespace(object.type)) {
savedObjectNamespace = initialNamespaces savedObjectNamespace = initialNamespaces
@ -536,6 +546,11 @@ export class SavedObjectsRepository {
versionProperties = getExpectedVersionProperties(version); versionProperties = getExpectedVersionProperties(version);
} }
// 1. If the originId has been *explicitly set* for the object (defined or undefined), respect that.
// 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any.
const originId = Object.keys(object).includes('originId')
? object.originId
: existingOriginId;
const migrated = this._migrator.migrateDocument({ const migrated = this._migrator.migrateDocument({
id: object.id, id: object.id,
type: object.type, type: object.type,
@ -546,7 +561,7 @@ export class SavedObjectsRepository {
...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }),
updated_at: time, updated_at: time,
references: object.references || [], references: object.references || [],
originId: object.originId, originId,
}) as SavedObjectSanitizedDoc<T>; }) as SavedObjectSanitizedDoc<T>;
/** /**
@ -2335,6 +2350,18 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createBadRequestError(error.message); throw SavedObjectsErrorHelpers.createBadRequestError(error.message);
} }
} }
/** This is used when objects are created. */
private validateOriginId(type: string, objectOrOptions: { originId?: string }) {
if (
Object.keys(objectOrOptions).includes('originId') &&
!this._registry.isMultiNamespace(type)
) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'"originId" can only be set for multi-namespace object types'
);
}
}
} }
/** /**