mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# Backport This will backport the following commits from `main` to `8.10`: - [[SOR] implement downward compatible `update` (#161822)](https://github.com/elastic/kibana/pull/161822) <!--- Backport version: 8.9.7 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Christiane (Tina) Heiligers","email":"christiane.heiligers@elastic.co"},"sourceCommit":{"committedDate":"2023-09-01T08:27:17Z","message":"[SOR] implement downward compatible `update` (#161822)\n\nPart of https://github.com/elastic/kibana/issues/152807\r\n\r\n## Summary\r\n\r\nChange the way the `update` API of the savedObjects repository works, to\r\nstop using ES `update` and perform the update manually instead by\r\nfetching the document, applying the update and then re-indexing the\r\nwhole document.\r\n\r\nThis is required for BWC and version cohabitation reasons, to allow us\r\nconverting the document to the last known version before applying the\r\nupdate.\r\n\r\nThe retry on conflict behavior is manually performed too, by detecting\r\nconflict errors during the `index` calls and performing the whole loop\r\n(fetch->migrate->update->index) again.\r\n\r\nUpserts are done in a similar way, by checking the document's existence\r\nfirst, and then using the `create` API.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: pgayvallet <pierre.gayvallet@elastic.co>","sha":"727929683e036abe1d5b8c2b05d9c0e5a7539b14","branchLabelMapping":{"^v8.11.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Core","Feature:Saved Objects","release_note:skip","backport:all-open","v8.10.0","v8.11.0"],"number":161822,"url":"https://github.com/elastic/kibana/pull/161822","mergeCommit":{"message":"[SOR] implement downward compatible `update` (#161822)\n\nPart of https://github.com/elastic/kibana/issues/152807\r\n\r\n## Summary\r\n\r\nChange the way the `update` API of the savedObjects repository works, to\r\nstop using ES `update` and perform the update manually instead by\r\nfetching the document, applying the update and then re-indexing the\r\nwhole document.\r\n\r\nThis is required for BWC and version cohabitation reasons, to allow us\r\nconverting the document to the last known version before applying the\r\nupdate.\r\n\r\nThe retry on conflict behavior is manually performed too, by detecting\r\nconflict errors during the `index` calls and performing the whole loop\r\n(fetch->migrate->update->index) again.\r\n\r\nUpserts are done in a similar way, by checking the document's existence\r\nfirst, and then using the `create` API.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: pgayvallet <pierre.gayvallet@elastic.co>","sha":"727929683e036abe1d5b8c2b05d9c0e5a7539b14"}},"sourceBranch":"main","suggestedTargetBranches":["8.10"],"targetPullRequestStates":[{"branch":"8.10","label":"v8.10.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.11.0","labelRegex":"^v8.11.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/161822","number":161822,"mergeCommit":{"message":"[SOR] implement downward compatible `update` (#161822)\n\nPart of https://github.com/elastic/kibana/issues/152807\r\n\r\n## Summary\r\n\r\nChange the way the `update` API of the savedObjects repository works, to\r\nstop using ES `update` and perform the update manually instead by\r\nfetching the document, applying the update and then re-indexing the\r\nwhole document.\r\n\r\nThis is required for BWC and version cohabitation reasons, to allow us\r\nconverting the document to the last known version before applying the\r\nupdate.\r\n\r\nThe retry on conflict behavior is manually performed too, by detecting\r\nconflict errors during the `index` calls and performing the whole loop\r\n(fetch->migrate->update->index) again.\r\n\r\nUpserts are done in a similar way, by checking the document's existence\r\nfirst, and then using the `create` API.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: pgayvallet <pierre.gayvallet@elastic.co>","sha":"727929683e036abe1d5b8c2b05d9c0e5a7539b14"}}]}] BACKPORT--> Co-authored-by: Christiane (Tina) Heiligers <christiane.heiligers@elastic.co>
This commit is contained in:
parent
b09d1c5bcc
commit
491db2bb1f
23 changed files with 1575 additions and 666 deletions
|
@ -23,6 +23,10 @@ export {
|
|||
type IPreflightCheckHelper,
|
||||
type PreflightCheckNamespacesParams,
|
||||
type PreflightCheckNamespacesResult,
|
||||
type PreflightDocParams,
|
||||
type PreflightDocResult,
|
||||
type PreflightNSParams,
|
||||
type PreflightNSResult,
|
||||
} from './preflight_check';
|
||||
|
||||
export interface RepositoryHelpers {
|
||||
|
|
|
@ -117,7 +117,6 @@ export class PreflightCheckHelper {
|
|||
if (!this.registry.isMultiNamespace(type)) {
|
||||
throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`);
|
||||
}
|
||||
|
||||
const { body, statusCode, headers } = await this.client.get<SavedObjectsRawDocSource>(
|
||||
{
|
||||
id: this.serializer.generateRawId(undefined, type, id),
|
||||
|
@ -151,8 +150,83 @@ export class PreflightCheckHelper {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight check fetching the document regardless of its namespace type for update.
|
||||
*/
|
||||
public async preflightGetDocForUpdate({
|
||||
type,
|
||||
id,
|
||||
namespace,
|
||||
}: PreflightDocParams): Promise<PreflightDocResult> {
|
||||
const { statusCode, body, headers } = await this.client.get<SavedObjectsRawDocSource>(
|
||||
{
|
||||
id: this.serializer.generateRawId(namespace, type, id),
|
||||
index: this.getIndexForType(type),
|
||||
},
|
||||
{ ignore: [404], meta: true }
|
||||
);
|
||||
|
||||
// checking if the 404 is from Elasticsearch
|
||||
if (isNotFoundFromUnsupportedServer({ statusCode, headers })) {
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id);
|
||||
}
|
||||
|
||||
const indexFound = statusCode !== 404;
|
||||
if (indexFound && isFoundGetResponse(body)) {
|
||||
return {
|
||||
checkDocFound: 'found',
|
||||
rawDocSource: body,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
checkDocFound: 'not_found',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight check to ensure that a multi-namespace object exists in the current namespace for update API.
|
||||
*/
|
||||
public preflightCheckNamespacesForUpdate({
|
||||
type,
|
||||
namespace,
|
||||
initialNamespaces,
|
||||
preflightDocResult,
|
||||
}: PreflightNSParams): PreflightNSResult {
|
||||
const { checkDocFound, rawDocSource } = preflightDocResult;
|
||||
if (!this.registry.isMultiNamespace(type)) {
|
||||
return {
|
||||
checkSkipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const namespaces = initialNamespaces ?? [SavedObjectsUtils.namespaceIdToString(namespace)];
|
||||
|
||||
if (checkDocFound === 'found' && rawDocSource !== undefined) {
|
||||
if (!rawDocExistsInNamespaces(this.registry, rawDocSource, namespaces)) {
|
||||
return { checkResult: 'found_outside_namespace', checkSkipped: false };
|
||||
}
|
||||
return {
|
||||
checkResult: 'found_in_namespace',
|
||||
savedObjectNamespaces:
|
||||
initialNamespaces ?? getSavedObjectNamespaces(namespace, rawDocSource),
|
||||
rawDocSource,
|
||||
checkSkipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
checkResult: 'not_found',
|
||||
savedObjectNamespaces: initialNamespaces ?? getSavedObjectNamespaces(namespace),
|
||||
checkSkipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight check to ensure that an upsert which would create a new object does not result in an alias conflict.
|
||||
*
|
||||
* If an upsert would result in the creation of a new object, we need to check for alias conflicts too.
|
||||
* This takes an extra round trip to Elasticsearch, but this won't happen often.
|
||||
*/
|
||||
public async preflightCheckForUpsertAliasConflict(
|
||||
type: string,
|
||||
|
@ -189,6 +263,39 @@ export interface PreflightCheckNamespacesParams {
|
|||
initialNamespaces?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface PreflightNSParams {
|
||||
/** The object type to fetch */
|
||||
type: string;
|
||||
/** The object ID to fetch */
|
||||
id: string;
|
||||
/** The current space */
|
||||
namespace: string | undefined;
|
||||
/** Optional; for an object that is being created, this specifies the initial namespace(s) it will exist in (overriding the current space) */
|
||||
initialNamespaces?: string[];
|
||||
/** Optional; for a pre-fetched object */
|
||||
preflightDocResult: PreflightDocResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface PreflightNSResult {
|
||||
/** If the object exists, and whether or not it exists in the current space */
|
||||
checkResult?: 'not_found' | 'found_in_namespace' | 'found_outside_namespace';
|
||||
/**
|
||||
* What namespace(s) the object should exist in, if it needs to be created; practically speaking, this will never be undefined if
|
||||
* checkResult == not_found or checkResult == found_in_namespace
|
||||
*/
|
||||
savedObjectNamespaces?: string[];
|
||||
/** The source of the raw document, if the object already exists */
|
||||
rawDocSource?: GetResponseFound<SavedObjectsRawDocSource>;
|
||||
/** Indicates if the namespaces check is called or not. Non-multinamespace types are not shareable */
|
||||
checkSkipped?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
|
@ -203,3 +310,30 @@ export interface PreflightCheckNamespacesResult {
|
|||
/** The source of the raw document, if the object already exists */
|
||||
rawDocSource?: GetResponseFound<SavedObjectsRawDocSource>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface PreflightDocParams {
|
||||
/** The object type to fetch */
|
||||
type: string;
|
||||
/** The object ID to fetch */
|
||||
id: string;
|
||||
/** The current space */
|
||||
namespace: string | undefined;
|
||||
/**
|
||||
* optional migration version compatibility.
|
||||
* {@link SavedObjectsRawDocParseOptions.migrationVersionCompatibility}
|
||||
*/
|
||||
migrationVersionCompatibility?: 'compatible' | 'raw';
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface PreflightDocResult {
|
||||
/** If the object exists, and whether or not it exists in the current space */
|
||||
checkDocFound: 'not_found' | 'found';
|
||||
/** The source of the raw document, if the object already exists in the server's version (unsafe to use) */
|
||||
rawDocSource?: GetResponseFound<SavedObjectsRawDocSource>;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,741 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
|
||||
import { mockGetCurrentTime, mockPreflightCheckForCreate } from '../repository.test.mock';
|
||||
|
||||
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import {
|
||||
type SavedObjectUnsanitizedDoc,
|
||||
type SavedObjectReference,
|
||||
SavedObjectsRawDocSource,
|
||||
SavedObjectsErrorHelpers,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server';
|
||||
import { SavedObjectsRepository } from '../repository';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import {
|
||||
SavedObjectsSerializer,
|
||||
encodeHitVersion,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { kibanaMigratorMock } from '../../mocks';
|
||||
import {
|
||||
NAMESPACE_AGNOSTIC_TYPE,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
HIDDEN_TYPE,
|
||||
mockVersionProps,
|
||||
mockTimestampFields,
|
||||
mockTimestamp,
|
||||
mappings,
|
||||
mockVersion,
|
||||
createRegistry,
|
||||
createDocumentMigrator,
|
||||
getMockGetResponse,
|
||||
createSpySerializer,
|
||||
createBadRequestErrorPayload,
|
||||
createConflictErrorPayload,
|
||||
createGenericNotFoundErrorPayload,
|
||||
updateSuccess,
|
||||
} from '../../test_helpers/repository.test.common';
|
||||
|
||||
describe('SavedObjectsRepository', () => {
|
||||
let client: ReturnType<typeof elasticsearchClientMock.createElasticsearchClient>;
|
||||
let repository: SavedObjectsRepository;
|
||||
let migrator: ReturnType<typeof kibanaMigratorMock.create>;
|
||||
let logger: ReturnType<typeof loggerMock.create>;
|
||||
let serializer: jest.Mocked<SavedObjectsSerializer>;
|
||||
|
||||
const registry = createRegistry();
|
||||
const documentMigrator = createDocumentMigrator(registry);
|
||||
|
||||
const expectMigrationArgs = (args: unknown, contains = true, n = 1) => {
|
||||
const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args);
|
||||
expect(migrator.migrateDocument).toHaveBeenNthCalledWith(
|
||||
n,
|
||||
obj,
|
||||
expect.objectContaining({
|
||||
allowDowngrade: expect.any(Boolean),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
client = elasticsearchClientMock.createElasticsearchClient();
|
||||
migrator = kibanaMigratorMock.create();
|
||||
documentMigrator.prepareMigrations();
|
||||
migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate);
|
||||
migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]);
|
||||
logger = loggerMock.create();
|
||||
|
||||
// create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation
|
||||
serializer = createSpySerializer(registry);
|
||||
|
||||
const allTypes = registry.getAllTypes().map((type) => type.name);
|
||||
const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))];
|
||||
|
||||
// @ts-expect-error must use the private constructor to use the mocked serializer
|
||||
repository = new SavedObjectsRepository({
|
||||
index: '.kibana-test',
|
||||
mappings,
|
||||
client,
|
||||
migrator,
|
||||
typeRegistry: registry,
|
||||
serializer,
|
||||
allowedTypes,
|
||||
logger,
|
||||
});
|
||||
|
||||
mockGetCurrentTime.mockReturnValue(mockTimestamp);
|
||||
});
|
||||
|
||||
describe('#update', () => {
|
||||
const id = 'logstash-*';
|
||||
const type = 'index-pattern';
|
||||
const attributes = { title: 'Testing' };
|
||||
const namespace = 'foo-namespace';
|
||||
const references = [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'test',
|
||||
id: '1',
|
||||
},
|
||||
];
|
||||
const originId = 'some-origin-id';
|
||||
const mockMigrationVersion = { foo: '2.3.4' };
|
||||
const mockMigrateDocumentForUpdate = (doc: SavedObjectUnsanitizedDoc<any>) => {
|
||||
const response = {
|
||||
...doc,
|
||||
attributes: {
|
||||
...doc.attributes,
|
||||
...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }),
|
||||
},
|
||||
migrationVersion: mockMigrationVersion,
|
||||
managed: doc.managed ?? false,
|
||||
references: doc.references || [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'test',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
};
|
||||
return response;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPreflightCheckForCreate.mockReset();
|
||||
mockPreflightCheckForCreate.mockImplementation(({ objects }) => {
|
||||
return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default
|
||||
});
|
||||
client.create.mockResponseImplementation((params) => {
|
||||
return {
|
||||
body: {
|
||||
_id: params.id,
|
||||
...mockVersionProps,
|
||||
} as estypes.CreateResponse,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('client calls', () => {
|
||||
it(`should use the ES get action then index action when type is not multi-namespace for existing objects`, async () => {
|
||||
const type = 'index-pattern';
|
||||
const id = 'logstash-*';
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, { namespace });
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`should use the ES get action then index action when type is multi-namespace for existing objects`, async () => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes
|
||||
);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`should use the ES get action then index action when type is namespace agnostic for existing objects`, async () => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(client, repository, registry, NAMESPACE_AGNOSTIC_TYPE, id, attributes);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`should check for alias conflicts if a new multi-namespace object before create action would be created then create action to create the object`, async () => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ upsert: true },
|
||||
{ mockGetResponseAsNotFound: { found: false } as estypes.GetResponse }
|
||||
);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1);
|
||||
expect(client.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`defaults to empty array with no input references`, async () => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(client, repository, registry, type, id, attributes);
|
||||
expect(
|
||||
(client.index.mock.calls[0][0] as estypes.CreateRequest<SavedObjectsRawDocSource>).body!
|
||||
.references
|
||||
).toEqual([]); // we're indexing a full new doc, serializer adds default if not defined
|
||||
});
|
||||
|
||||
it(`accepts custom references array 1`, async () => {
|
||||
const test = async (references: SavedObjectReference[]) => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
references,
|
||||
});
|
||||
expect(
|
||||
(client.index.mock.calls[0][0] as estypes.CreateRequest<SavedObjectsRawDocSource>).body!
|
||||
.references
|
||||
).toEqual(references);
|
||||
client.index.mockClear();
|
||||
};
|
||||
await test(references);
|
||||
});
|
||||
it(`accepts custom references array 2`, async () => {
|
||||
const test = async (references: SavedObjectReference[]) => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
references,
|
||||
});
|
||||
expect(
|
||||
(client.index.mock.calls[0][0] as estypes.CreateRequest<SavedObjectsRawDocSource>).body!
|
||||
.references
|
||||
).toEqual(references);
|
||||
client.index.mockClear();
|
||||
};
|
||||
await test([{ type: 'foo', id: '42', name: 'some ref' }]);
|
||||
});
|
||||
it(`accepts custom references array 3`, async () => {
|
||||
const test = async (references: SavedObjectReference[]) => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
references,
|
||||
});
|
||||
expect(
|
||||
(client.index.mock.calls[0][0] as estypes.CreateRequest<SavedObjectsRawDocSource>).body!
|
||||
.references
|
||||
).toEqual(references);
|
||||
client.index.mockClear();
|
||||
};
|
||||
await test([]);
|
||||
});
|
||||
|
||||
it(`uses the 'upsertAttributes' option when specified for a single-namespace type that does not exist`, async () => {
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
type,
|
||||
id,
|
||||
attributes,
|
||||
{
|
||||
upsert: {
|
||||
title: 'foo',
|
||||
description: 'bar',
|
||||
},
|
||||
},
|
||||
{ mockGetResponseAsNotFound: { found: false } as estypes.GetResponse }
|
||||
);
|
||||
|
||||
const expected = {
|
||||
'index-pattern': { description: 'bar', title: 'foo' },
|
||||
type: 'index-pattern',
|
||||
...mockTimestampFields,
|
||||
};
|
||||
expect(
|
||||
(client.create.mock.calls[0][0] as estypes.CreateRequest<SavedObjectsRawDocSource>).body!
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it(`uses the 'upsertAttributes' option when specified for a multi-namespace type that does not exist`, async () => {
|
||||
const options = { upsert: { title: 'foo', description: 'bar' } };
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{
|
||||
upsert: {
|
||||
title: 'foo',
|
||||
description: 'bar',
|
||||
},
|
||||
},
|
||||
{
|
||||
mockGetResponseAsNotFound: { found: false } as estypes.GetResponse,
|
||||
}
|
||||
);
|
||||
await repository.update(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(2);
|
||||
const expectedType = {
|
||||
multiNamespaceIsolatedType: { description: 'bar', title: 'foo' },
|
||||
namespaces: ['default'],
|
||||
type: 'multiNamespaceIsolatedType',
|
||||
...mockTimestampFields,
|
||||
};
|
||||
expect(
|
||||
(client.create.mock.calls[0][0] as estypes.CreateRequest<SavedObjectsRawDocSource>).body!
|
||||
).toEqual(expectedType);
|
||||
});
|
||||
|
||||
it(`ignores the 'upsertAttributes' option when specified for a multi-namespace type that already exists`, async () => {
|
||||
// attributes don't change
|
||||
const options = { upsert: { title: 'foo', description: 'bar' } };
|
||||
migrator.migrateDocument.mockImplementation((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
options
|
||||
);
|
||||
await repository.update(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, options);
|
||||
expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`,
|
||||
index: '.kibana-test_8.0.0-testing',
|
||||
refresh: 'wait_for',
|
||||
require_alias: true,
|
||||
body: expect.objectContaining({
|
||||
multiNamespaceIsolatedType: { title: 'Testing' },
|
||||
namespaces: ['default'],
|
||||
references: [],
|
||||
type: 'multiNamespaceIsolatedType',
|
||||
...mockTimestampFields,
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't accept custom references if not an array`, async () => {
|
||||
const test = async (references: unknown) => {
|
||||
migrator.migrateDocument.mockImplementation(mockMigrateDocumentForUpdate);
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
// @ts-expect-error references is unknown
|
||||
references,
|
||||
});
|
||||
expect(
|
||||
(client.index.mock.calls[0][0] as estypes.CreateRequest<SavedObjectsRawDocSource>).body!
|
||||
.references
|
||||
).toEqual([]);
|
||||
client.index.mockClear();
|
||||
client.create.mockClear();
|
||||
};
|
||||
await test('string');
|
||||
await test(123);
|
||||
await test(true);
|
||||
await test(null);
|
||||
});
|
||||
|
||||
it(`defaults to a refresh setting of wait_for`, async () => {
|
||||
migrator.migrateDocument.mockImplementation(mockMigrateDocumentForUpdate);
|
||||
await updateSuccess(client, repository, registry, type, id, { foo: 'bar' });
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
refresh: 'wait_for',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`defaults to the version of the existing document when type is multi-namespace`, async () => {
|
||||
migrator.migrateDocument.mockImplementation(mockMigrateDocumentForUpdate);
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ references }
|
||||
);
|
||||
const versionProperties = {
|
||||
if_seq_no: mockVersionProps._seq_no,
|
||||
if_primary_term: mockVersionProps._primary_term,
|
||||
};
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining(versionProperties),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`accepts version`, async () => {
|
||||
migrator.migrateDocument.mockImplementation(mockMigrateDocumentForUpdate);
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }),
|
||||
});
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ if_seq_no: 100, if_primary_term: 200 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('retries the operation in case of conflict error', async () => {
|
||||
client.get.mockResponse(getMockGetResponse(registry, { type, id }));
|
||||
|
||||
client.index
|
||||
.mockImplementationOnce(() => {
|
||||
throw SavedObjectsErrorHelpers.createConflictError(type, id, 'conflict');
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw SavedObjectsErrorHelpers.createConflictError(type, id, 'conflict');
|
||||
})
|
||||
.mockResponseImplementation((params) => {
|
||||
return {
|
||||
body: {
|
||||
_id: params.id,
|
||||
_seq_no: 1,
|
||||
_primary_term: 1,
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
await repository.update(type, id, attributes, { retryOnConflict: 3 });
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(3);
|
||||
expect(client.index).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries the operation a maximum of `retryOnConflict` times', async () => {
|
||||
client.get.mockResponse(getMockGetResponse(registry, { type, id }));
|
||||
|
||||
client.index.mockImplementation(() => {
|
||||
throw SavedObjectsErrorHelpers.createConflictError(type, id, 'conflict');
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.update(type, id, attributes, { retryOnConflict: 3 })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Saved object [index-pattern/logstash-*] conflict"`
|
||||
);
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(4);
|
||||
expect(client.index).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('default to a `retry_on_conflict` setting of `0` when `version` is provided', async () => {
|
||||
client.get.mockResponse(getMockGetResponse(registry, { type, id }));
|
||||
|
||||
client.index.mockImplementation(() => {
|
||||
throw SavedObjectsErrorHelpers.createConflictError(type, id, 'conflict');
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.update(type, id, attributes, {
|
||||
version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }),
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Saved object [index-pattern/logstash-*] conflict"`
|
||||
);
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, { namespace });
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }), // namespace expected: globalType
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, { references });
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
references,
|
||||
namespace: 'default',
|
||||
});
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when using agnostic-namespace type`, async () => {
|
||||
await updateSuccess(client, repository, registry, NAMESPACE_AGNOSTIC_TYPE, id, attributes, {
|
||||
namespace,
|
||||
});
|
||||
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when using multi-namespace type`, async () => {
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ namespace }
|
||||
);
|
||||
expect(client.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
const expectNotFoundError = async (type: string, id: string) => {
|
||||
await expect(
|
||||
repository.update(type, id, {}, { migrationVersionCompatibility: 'raw' })
|
||||
).rejects.toThrowError(createGenericNotFoundErrorPayload(type, id));
|
||||
};
|
||||
|
||||
it(`throws when options.namespace is '*'`, async () => {
|
||||
await expect(
|
||||
repository.update(type, id, attributes, { namespace: ALL_NAMESPACES_STRING })
|
||||
).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"'));
|
||||
});
|
||||
|
||||
it(`throws when type is invalid`, async () => {
|
||||
await expectNotFoundError('unknownType', id);
|
||||
expect(client.index).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when type is hidden`, async () => {
|
||||
await expectNotFoundError(HIDDEN_TYPE, id);
|
||||
expect(client.index).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when id is empty`, async () => {
|
||||
await expect(repository.update(type, '', attributes)).rejects.toThrowError(
|
||||
createBadRequestErrorPayload('id cannot be empty')
|
||||
);
|
||||
expect(client.index).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when ES is unable to find the document during get`, async () => {
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(
|
||||
{ found: false } as estypes.GetResponse,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`throws when ES is unable to find the index during get`, async () => {
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({} as estypes.GetResponse, {
|
||||
statusCode: 404,
|
||||
})
|
||||
);
|
||||
await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => {
|
||||
const response = getMockGetResponse(
|
||||
registry,
|
||||
{ type: MULTI_NAMESPACE_ISOLATED_TYPE, id },
|
||||
namespace
|
||||
);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`throws when there is an alias conflict from preflightCheckForCreate`, async () => {
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({
|
||||
found: false,
|
||||
} as estypes.GetResponse)
|
||||
);
|
||||
mockPreflightCheckForCreate.mockResolvedValue([
|
||||
{ type: 'type', id: 'id', error: { type: 'aliasConflict' } },
|
||||
]);
|
||||
await expect(
|
||||
repository.update(
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
{ attr: 'value' },
|
||||
{
|
||||
upsert: {
|
||||
upsertAttr: 'val',
|
||||
attr: 'value',
|
||||
},
|
||||
}
|
||||
)
|
||||
).rejects.toThrowError(createConflictErrorPayload(MULTI_NAMESPACE_ISOLATED_TYPE, id));
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`does not throw when there is a different error from preflightCheckForCreate`, async () => {
|
||||
mockPreflightCheckForCreate.mockResolvedValue([
|
||||
{ type: 'type', id: 'id', error: { type: 'conflict' } },
|
||||
]);
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ upsert: true },
|
||||
{ mockGetResponseAsNotFound: { found: false } as estypes.GetResponse }
|
||||
);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1);
|
||||
expect(client.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`does not throw when the document does not exist`, async () => {
|
||||
expect(client.create).not.toHaveBeenCalled();
|
||||
await expectNotFoundError(type, id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('migrates the fetched document from get', async () => {
|
||||
const type = 'index-pattern';
|
||||
const id = 'logstash-*';
|
||||
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
|
||||
await updateSuccess(client, repository, registry, type, id, attributes);
|
||||
expect(migrator.migrateDocument).toHaveBeenCalledTimes(2);
|
||||
expectMigrationArgs({
|
||||
id,
|
||||
type,
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates the input arguments when upsert is used', async () => {
|
||||
const options = {
|
||||
upsert: {
|
||||
title: 'foo',
|
||||
description: 'bar',
|
||||
},
|
||||
};
|
||||
const internalOptions = {
|
||||
mockGetResponseAsNotFound: { found: false } as estypes.GetResponse,
|
||||
};
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
type,
|
||||
id,
|
||||
attributes,
|
||||
options,
|
||||
internalOptions
|
||||
);
|
||||
expect(migrator.migrateDocument).toHaveBeenCalledTimes(1);
|
||||
expectMigrationArgs({
|
||||
id,
|
||||
type,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns', () => {
|
||||
it(`returns _seq_no and _primary_term encoded as version`, async () => {
|
||||
const result = await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
namespace,
|
||||
references,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id,
|
||||
type,
|
||||
...mockTimestampFields,
|
||||
version: mockVersion,
|
||||
attributes,
|
||||
references,
|
||||
namespaces: [namespace],
|
||||
});
|
||||
});
|
||||
|
||||
it(`includes namespaces if type is multi-namespace`, async () => {
|
||||
const result = await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
namespaces: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it(`includes namespaces if type is not multi-namespace`, async () => {
|
||||
const result = await updateSuccess(client, repository, registry, type, id, attributes);
|
||||
expect(result).toMatchObject({
|
||||
namespaces: ['default'],
|
||||
});
|
||||
});
|
||||
|
||||
it(`includes originId property if present in cluster call response`, async () => {
|
||||
const result = await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
type,
|
||||
id,
|
||||
attributes,
|
||||
{},
|
||||
{ originId }
|
||||
);
|
||||
expect(result).toMatchObject({ originId });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -10,19 +10,21 @@ import {
|
|||
SavedObjectsErrorHelpers,
|
||||
type SavedObject,
|
||||
type SavedObjectSanitizedDoc,
|
||||
SavedObjectsRawDoc,
|
||||
SavedObjectsRawDocSource,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server';
|
||||
import { encodeHitVersion } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
decodeRequestVersion,
|
||||
encodeHitVersion,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import type {
|
||||
SavedObjectsUpdateOptions,
|
||||
SavedObjectsUpdateResponse,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal';
|
||||
import { DEFAULT_REFRESH_SETTING, DEFAULT_RETRY_COUNT } from '../constants';
|
||||
import { getCurrentTime, getExpectedVersionProperties } from './utils';
|
||||
import { ApiExecutionContext } from './types';
|
||||
import { PreflightCheckNamespacesResult } from './helpers';
|
||||
import { isValidRequest } from '../utils';
|
||||
import { getCurrentTime, getSavedObjectFromSource, mergeForUpdate } from './utils';
|
||||
import type { ApiExecutionContext } from './types';
|
||||
|
||||
export interface PerformUpdateParams<T = unknown> {
|
||||
type: string;
|
||||
|
@ -32,84 +34,141 @@ export interface PerformUpdateParams<T = unknown> {
|
|||
}
|
||||
|
||||
export const performUpdate = async <T>(
|
||||
updateParams: PerformUpdateParams<T>,
|
||||
apiContext: ApiExecutionContext
|
||||
): Promise<SavedObjectsUpdateResponse<T>> => {
|
||||
const { type, id, options } = updateParams;
|
||||
const { allowedTypes, helpers } = apiContext;
|
||||
const namespace = helpers.common.getCurrentNamespace(options.namespace);
|
||||
|
||||
// check request is valid
|
||||
const { validRequest, error } = isValidRequest({ allowedTypes, type, id });
|
||||
if (!validRequest && error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const maxAttempts = options.version ? 1 : 1 + DEFAULT_RETRY_COUNT;
|
||||
|
||||
// handle retryOnConflict manually by reattempting the operation in case of conflict errors
|
||||
let response: SavedObjectsUpdateResponse<T>;
|
||||
for (let currentAttempt = 1; currentAttempt <= maxAttempts; currentAttempt++) {
|
||||
try {
|
||||
response = await executeUpdate(updateParams, apiContext, { namespace });
|
||||
break;
|
||||
} catch (e) {
|
||||
if (
|
||||
SavedObjectsErrorHelpers.isConflictError(e) &&
|
||||
e.retryableConflict &&
|
||||
currentAttempt < maxAttempts
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return response!;
|
||||
};
|
||||
|
||||
export const executeUpdate = async <T>(
|
||||
{ id, type, attributes, options }: PerformUpdateParams<T>,
|
||||
{
|
||||
registry,
|
||||
helpers,
|
||||
allowedTypes,
|
||||
client,
|
||||
serializer,
|
||||
migrator,
|
||||
extensions = {},
|
||||
}: ApiExecutionContext
|
||||
{ registry, helpers, client, serializer, extensions = {}, logger }: ApiExecutionContext,
|
||||
{ namespace }: { namespace: string | undefined }
|
||||
): Promise<SavedObjectsUpdateResponse<T>> => {
|
||||
const {
|
||||
common: commonHelper,
|
||||
encryption: encryptionHelper,
|
||||
preflight: preflightHelper,
|
||||
migration: migrationHelper,
|
||||
validation: validationHelper,
|
||||
} = helpers;
|
||||
const { securityExtension } = extensions;
|
||||
|
||||
const namespace = commonHelper.getCurrentNamespace(options.namespace);
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
}
|
||||
if (!id) {
|
||||
throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID
|
||||
}
|
||||
|
||||
const {
|
||||
version,
|
||||
references,
|
||||
upsert,
|
||||
refresh = DEFAULT_REFRESH_SETTING,
|
||||
retryOnConflict = version ? 0 : DEFAULT_RETRY_COUNT,
|
||||
migrationVersionCompatibility,
|
||||
} = options;
|
||||
|
||||
let preflightResult: PreflightCheckNamespacesResult | undefined;
|
||||
if (registry.isMultiNamespace(type)) {
|
||||
preflightResult = await preflightHelper.preflightCheckNamespaces({
|
||||
type,
|
||||
id,
|
||||
namespace,
|
||||
});
|
||||
}
|
||||
// Preflight calls to get the doc and check namespaces for multinamespace types.
|
||||
const preflightDocResult = await preflightHelper.preflightGetDocForUpdate({
|
||||
type,
|
||||
id,
|
||||
namespace,
|
||||
});
|
||||
|
||||
const existingNamespaces = preflightResult?.savedObjectNamespaces ?? [];
|
||||
const preflightDocNSResult = preflightHelper.preflightCheckNamespacesForUpdate({
|
||||
type,
|
||||
id,
|
||||
namespace,
|
||||
preflightDocResult,
|
||||
});
|
||||
|
||||
const existingNamespaces = preflightDocNSResult?.savedObjectNamespaces ?? [];
|
||||
const authorizationResult = await securityExtension?.authorizeUpdate({
|
||||
namespace,
|
||||
object: { type, id, existingNamespaces },
|
||||
});
|
||||
|
||||
if (
|
||||
preflightResult?.checkResult === 'found_outside_namespace' ||
|
||||
(!upsert && preflightResult?.checkResult === 'not_found')
|
||||
) {
|
||||
// validate if an update (directly update or create the object instead) can be done, based on if the doc exists or not
|
||||
const docOutsideNamespace = preflightDocNSResult?.checkResult === 'found_outside_namespace';
|
||||
const docNotFound =
|
||||
preflightDocNSResult?.checkResult === 'not_found' ||
|
||||
preflightDocResult.checkDocFound === 'not_found';
|
||||
|
||||
// doc not in namespace, or doc not found but we're not upserting => throw 404
|
||||
if (docOutsideNamespace || (docNotFound && !upsert)) {
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
}
|
||||
if (upsert && preflightResult?.checkResult === 'not_found') {
|
||||
// If an upsert would result in the creation of a new object, we need to check for alias conflicts too.
|
||||
// This takes an extra round trip to Elasticsearch, but this won't happen often.
|
||||
// TODO: improve performance by combining these into a single preflight check
|
||||
|
||||
if (upsert && preflightDocNSResult?.checkResult === 'not_found') {
|
||||
// we only need to check multi-namespace objects. Single and agnostic types do not have aliases.
|
||||
// throws SavedObjectsErrorHelpers.createConflictError(type, id) if there is one
|
||||
await preflightHelper.preflightCheckForUpsertAliasConflict(type, id, namespace);
|
||||
}
|
||||
const time = getCurrentTime();
|
||||
|
||||
let rawUpsert: SavedObjectsRawDoc | undefined;
|
||||
// don't include upsert if the object already exists; ES doesn't allow upsert in combination with version properties
|
||||
if (upsert && (!preflightResult || preflightResult.checkResult === 'not_found')) {
|
||||
let savedObjectNamespace: string | undefined;
|
||||
let savedObjectNamespaces: string[] | undefined;
|
||||
|
||||
if (registry.isSingleNamespace(type) && namespace) {
|
||||
savedObjectNamespace = namespace;
|
||||
} else if (registry.isMultiNamespace(type)) {
|
||||
savedObjectNamespaces = preflightResult!.savedObjectNamespaces;
|
||||
// migrate the existing doc to the current version
|
||||
let migrated: SavedObject<T>;
|
||||
if (preflightDocResult.checkDocFound === 'found') {
|
||||
const document = getSavedObjectFromSource<T>(
|
||||
registry,
|
||||
type,
|
||||
id,
|
||||
preflightDocResult.rawDocSource!,
|
||||
{ migrationVersionCompatibility }
|
||||
);
|
||||
try {
|
||||
migrated = migrationHelper.migrateStorageDocument(document) as SavedObject<T>;
|
||||
} catch (migrateStorageDocError) {
|
||||
throw SavedObjectsErrorHelpers.decorateGeneralError(
|
||||
migrateStorageDocError,
|
||||
'Failed to migrate document to the latest version.'
|
||||
);
|
||||
}
|
||||
}
|
||||
// END ALL PRE_CLIENT CALL CHECKS && MIGRATE EXISTING DOC;
|
||||
|
||||
const migrated = migrationHelper.migrateInputDocument({
|
||||
const time = getCurrentTime();
|
||||
let updatedOrCreatedSavedObject: SavedObject<T>;
|
||||
// `upsert` option set and document was not found -> we need to perform an upsert operation
|
||||
const shouldPerformUpsert = upsert && docNotFound;
|
||||
|
||||
let savedObjectNamespace: string | undefined;
|
||||
let savedObjectNamespaces: string[] | undefined;
|
||||
|
||||
if (namespace && registry.isSingleNamespace(type)) {
|
||||
savedObjectNamespace = namespace;
|
||||
} else if (registry.isMultiNamespace(type)) {
|
||||
savedObjectNamespaces = preflightDocNSResult.savedObjectNamespaces;
|
||||
}
|
||||
|
||||
// UPSERT CASE START
|
||||
if (shouldPerformUpsert) {
|
||||
// ignore attributes if creating a new doc: only use the upsert attributes
|
||||
// don't include upsert if the object already exists; ES doesn't allow upsert in combination with version properties
|
||||
const migratedUpsert = migrationHelper.migrateInputDocument({
|
||||
id,
|
||||
type,
|
||||
...(savedObjectNamespace && { namespace: savedObjectNamespace }),
|
||||
|
@ -118,31 +177,25 @@ export const performUpdate = async <T>(
|
|||
...(await encryptionHelper.optionallyEncryptAttributes(type, id, namespace, upsert)),
|
||||
},
|
||||
updated_at: time,
|
||||
});
|
||||
rawUpsert = serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc);
|
||||
}
|
||||
...(Array.isArray(references) && { references }),
|
||||
}) as SavedObjectSanitizedDoc<T>;
|
||||
validationHelper.validateObjectForCreate(type, migratedUpsert);
|
||||
const rawUpsert = serializer.savedObjectToRaw(migratedUpsert);
|
||||
|
||||
const doc = {
|
||||
[type]: await encryptionHelper.optionallyEncryptAttributes(type, id, namespace, attributes),
|
||||
updated_at: time,
|
||||
...(Array.isArray(references) && { references }),
|
||||
};
|
||||
|
||||
const body = await client
|
||||
.update<unknown, unknown, SavedObjectsRawDocSource>({
|
||||
id: serializer.generateRawId(namespace, type, id),
|
||||
const createRequestParams = {
|
||||
id: rawUpsert._id,
|
||||
index: commonHelper.getIndexForType(type),
|
||||
...getExpectedVersionProperties(version),
|
||||
refresh,
|
||||
retry_on_conflict: retryOnConflict,
|
||||
body: {
|
||||
doc,
|
||||
...(rawUpsert && { upsert: rawUpsert._source }),
|
||||
},
|
||||
_source_includes: ['namespace', 'namespaces', 'originId'],
|
||||
body: rawUpsert._source,
|
||||
...(version ? decodeRequestVersion(version) : {}),
|
||||
require_alias: true,
|
||||
})
|
||||
.catch((err) => {
|
||||
};
|
||||
|
||||
const {
|
||||
body: createDocResponseBody,
|
||||
statusCode,
|
||||
headers,
|
||||
} = await client.create(createRequestParams, { meta: true }).catch((err) => {
|
||||
if (SavedObjectsErrorHelpers.isEsUnavailableError(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
@ -150,31 +203,136 @@ export const performUpdate = async <T>(
|
|||
// see "404s from missing index" above
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
}
|
||||
if (SavedObjectsErrorHelpers.isConflictError(err)) {
|
||||
// flag the error as being caused by an update conflict
|
||||
err.retryableConflict = true;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (isNotFoundFromUnsupportedServer({ statusCode, headers })) {
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(id, type);
|
||||
}
|
||||
// client.create doesn't return the index document.
|
||||
// Use rawUpsert as the _source
|
||||
const upsertedSavedObject = serializer.rawToSavedObject<T>(
|
||||
{
|
||||
...rawUpsert,
|
||||
...createDocResponseBody,
|
||||
},
|
||||
{ migrationVersionCompatibility }
|
||||
);
|
||||
const { originId } = upsertedSavedObject ?? {};
|
||||
let namespaces: string[] = [];
|
||||
if (!registry.isNamespaceAgnostic(type)) {
|
||||
namespaces = upsertedSavedObject.namespaces ?? [
|
||||
SavedObjectsUtils.namespaceIdToString(upsertedSavedObject.namespace),
|
||||
];
|
||||
}
|
||||
|
||||
const { originId } = body.get?._source ?? {};
|
||||
let namespaces: string[] = [];
|
||||
if (!registry.isNamespaceAgnostic(type)) {
|
||||
namespaces = body.get?._source.namespaces ?? [
|
||||
SavedObjectsUtils.namespaceIdToString(body.get?._source.namespace),
|
||||
];
|
||||
updatedOrCreatedSavedObject = {
|
||||
id,
|
||||
type,
|
||||
updated_at: time,
|
||||
version: encodeHitVersion(createDocResponseBody),
|
||||
namespaces,
|
||||
...(originId && { originId }),
|
||||
references,
|
||||
attributes: upsert, // these ignore the attribute values provided in the main request body.
|
||||
} as SavedObject<T>;
|
||||
|
||||
// UPSERT CASE END
|
||||
} else {
|
||||
// UPDATE CASE START
|
||||
// at this point, we already know 1. the document exists 2. we're not doing an upsert
|
||||
// therefor we can safely process with the "standard" update sequence.
|
||||
|
||||
const updatedAttributes = mergeForUpdate(
|
||||
{ ...migrated!.attributes },
|
||||
await encryptionHelper.optionallyEncryptAttributes(type, id, namespace, attributes)
|
||||
);
|
||||
const migratedUpdatedSavedObjectDoc = migrationHelper.migrateInputDocument({
|
||||
...migrated!,
|
||||
id,
|
||||
type,
|
||||
// need to override the redacted NS values from the decrypted/migrated document
|
||||
namespace: savedObjectNamespace,
|
||||
namespaces: savedObjectNamespaces,
|
||||
attributes: updatedAttributes,
|
||||
updated_at: time,
|
||||
...(Array.isArray(references) && { references }),
|
||||
});
|
||||
|
||||
const docToSend = serializer.savedObjectToRaw(
|
||||
migratedUpdatedSavedObjectDoc as SavedObjectSanitizedDoc
|
||||
);
|
||||
|
||||
// implement creating the call params
|
||||
const indexRequestParams = {
|
||||
id: docToSend._id,
|
||||
index: commonHelper.getIndexForType(type),
|
||||
refresh,
|
||||
body: docToSend._source,
|
||||
// using version from the source doc if not provided as option to avoid erasing changes in case of concurrent calls
|
||||
...decodeRequestVersion(version || migrated!.version),
|
||||
require_alias: true,
|
||||
};
|
||||
|
||||
const {
|
||||
body: indexDocResponseBody,
|
||||
statusCode,
|
||||
headers,
|
||||
} = await client.index(indexRequestParams, { meta: true }).catch((err) => {
|
||||
if (SavedObjectsErrorHelpers.isEsUnavailableError(err)) {
|
||||
throw err;
|
||||
}
|
||||
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
|
||||
// see "404s from missing index" above
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
}
|
||||
if (SavedObjectsErrorHelpers.isConflictError(err)) {
|
||||
// flag the error as being caused by an update conflict
|
||||
err.retryableConflict = true;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
// throw if we can't verify a 404 response is from Elasticsearch
|
||||
if (isNotFoundFromUnsupportedServer({ statusCode, headers })) {
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(id, type);
|
||||
}
|
||||
// client.index doesn't return the indexed document.
|
||||
// Rather than making another round trip to elasticsearch to fetch the doc, we use the SO we sent
|
||||
// rawToSavedObject adds references as [] if undefined
|
||||
const updatedSavedObject = serializer.rawToSavedObject<T>(
|
||||
{
|
||||
...docToSend,
|
||||
...indexDocResponseBody,
|
||||
},
|
||||
{ migrationVersionCompatibility }
|
||||
);
|
||||
|
||||
const { originId } = updatedSavedObject ?? {};
|
||||
let namespaces: string[] = [];
|
||||
if (!registry.isNamespaceAgnostic(type)) {
|
||||
namespaces = updatedSavedObject.namespaces ?? [
|
||||
SavedObjectsUtils.namespaceIdToString(updatedSavedObject.namespace),
|
||||
];
|
||||
}
|
||||
|
||||
updatedOrCreatedSavedObject = {
|
||||
id,
|
||||
type,
|
||||
updated_at: time,
|
||||
version: encodeHitVersion(indexDocResponseBody),
|
||||
namespaces,
|
||||
...(originId && { originId }),
|
||||
references,
|
||||
attributes,
|
||||
} as SavedObject<T>;
|
||||
}
|
||||
|
||||
const result = {
|
||||
id,
|
||||
type,
|
||||
updated_at: time,
|
||||
version: encodeHitVersion(body),
|
||||
namespaces,
|
||||
...(originId && { originId }),
|
||||
references,
|
||||
attributes,
|
||||
} as SavedObject<T>;
|
||||
|
||||
return encryptionHelper.optionallyDecryptAndRedactSingleResult(
|
||||
result,
|
||||
updatedOrCreatedSavedObject!,
|
||||
authorizationResult?.typeMap,
|
||||
attributes
|
||||
shouldPerformUpsert ? upsert : attributes
|
||||
);
|
||||
};
|
||||
|
|
|
@ -23,3 +23,4 @@ export {
|
|||
type GetSavedObjectFromSourceOptions,
|
||||
} from './internal_utils';
|
||||
export { type Left, type Either, type Right, isLeft, isRight, left, right } from './either';
|
||||
export { mergeForUpdate } from './merge_for_update';
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { mergeForUpdate } from './merge_for_update';
|
||||
|
||||
describe('mergeForUpdate', () => {
|
||||
it('merges top level properties', () => {
|
||||
expect(mergeForUpdate({ foo: 'bar', hello: 'dolly' }, { baz: 42 })).toEqual({
|
||||
foo: 'bar',
|
||||
hello: 'dolly',
|
||||
baz: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides top level properties', () => {
|
||||
expect(mergeForUpdate({ foo: 'bar', hello: 'dolly' }, { baz: 42, foo: '9000' })).toEqual({
|
||||
foo: '9000',
|
||||
hello: 'dolly',
|
||||
baz: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores undefined top level properties', () => {
|
||||
expect(mergeForUpdate({ foo: 'bar', hello: 'dolly' }, { baz: 42, foo: undefined })).toEqual({
|
||||
foo: 'bar',
|
||||
hello: 'dolly',
|
||||
baz: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('merges nested properties', () => {
|
||||
expect(
|
||||
mergeForUpdate({ nested: { foo: 'bar', hello: 'dolly' } }, { nested: { baz: 42 } })
|
||||
).toEqual({
|
||||
nested: {
|
||||
foo: 'bar',
|
||||
hello: 'dolly',
|
||||
baz: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides nested properties', () => {
|
||||
expect(
|
||||
mergeForUpdate(
|
||||
{ nested: { foo: 'bar', hello: 'dolly' } },
|
||||
{ nested: { baz: 42, foo: '9000' } }
|
||||
)
|
||||
).toEqual({
|
||||
nested: {
|
||||
foo: '9000',
|
||||
hello: 'dolly',
|
||||
baz: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores undefined nested properties', () => {
|
||||
expect(
|
||||
mergeForUpdate(
|
||||
{ nested: { foo: 'bar', hello: 'dolly' } },
|
||||
{ nested: { baz: 42, foo: undefined } }
|
||||
)
|
||||
).toEqual({
|
||||
nested: {
|
||||
foo: 'bar',
|
||||
hello: 'dolly',
|
||||
baz: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('functions with mixed levels of properties', () => {
|
||||
expect(
|
||||
mergeForUpdate(
|
||||
{ rootPropA: 'A', nested: { foo: 'bar', hello: 'dolly', deep: { deeper: 'we need' } } },
|
||||
{ rootPropB: 'B', nested: { baz: 42, foo: '9000', deep: { deeper: 'we are' } } }
|
||||
)
|
||||
).toEqual({
|
||||
rootPropA: 'A',
|
||||
rootPropB: 'B',
|
||||
nested: {
|
||||
foo: '9000',
|
||||
hello: 'dolly',
|
||||
baz: 42,
|
||||
deep: {
|
||||
deeper: 'we are',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isPlainObject } from 'lodash';
|
||||
import { set } from '@kbn/safer-lodash-set';
|
||||
|
||||
export const mergeForUpdate = (
|
||||
targetAttributes: Record<string, any>,
|
||||
updatedAttributes: any
|
||||
): Record<string, any> => {
|
||||
return recursiveMerge(targetAttributes, updatedAttributes, []);
|
||||
};
|
||||
|
||||
const recursiveMerge = (target: Record<string, any>, value: any, keys: string[] = []) => {
|
||||
if (isPlainObject(value) && Object.keys(value).length > 0) {
|
||||
for (const [subKey, subVal] of Object.entries(value)) {
|
||||
recursiveMerge(target, subVal, [...keys, subKey]);
|
||||
}
|
||||
} else if (keys.length > 0 && value !== undefined) {
|
||||
set(target, keys, value);
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
|
@ -350,7 +350,7 @@ describe('SavedObjectsRepository Encryption Extension', () => {
|
|||
namespace,
|
||||
}
|
||||
);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // (no upsert) optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).not.toHaveBeenCalled();
|
||||
|
@ -382,7 +382,7 @@ describe('SavedObjectsRepository Encryption Extension', () => {
|
|||
references: encryptedSO.references,
|
||||
}
|
||||
);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // (no upsert) optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
|
|
|
@ -235,7 +235,7 @@ describe('SavedObjectsRepository Security Extension', () => {
|
|||
});
|
||||
|
||||
expect(mockSecurityExt.authorizeUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ id, type, attributes, namespaces: [namespace] })
|
||||
);
|
||||
|
@ -250,7 +250,7 @@ describe('SavedObjectsRepository Security Extension', () => {
|
|||
});
|
||||
|
||||
expect(mockSecurityExt.authorizeUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ id, type, attributes, namespaces: [namespace] })
|
||||
);
|
||||
|
|
|
@ -222,27 +222,26 @@ describe('SavedObjectsRepository Spaces Extension', () => {
|
|||
id,
|
||||
{},
|
||||
{ upsert: true },
|
||||
{ mockGetResponseValue: { found: false } as estypes.GetResponse }
|
||||
{ mockGetResponseAsNotFound: { found: false } as estypes.GetResponse },
|
||||
[currentSpace.expectedNamespace ?? 'default']
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect(client.create).toHaveBeenCalledTimes(1);
|
||||
expect(client.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${type}:${id}`,
|
||||
body: expect.objectContaining({
|
||||
upsert: expect.objectContaining(
|
||||
currentSpace.expectedNamespace
|
||||
? {
|
||||
namespace: currentSpace.expectedNamespace,
|
||||
}
|
||||
: {}
|
||||
),
|
||||
}),
|
||||
body: expect.objectContaining(
|
||||
currentSpace.expectedNamespace
|
||||
? {
|
||||
namespace: currentSpace.expectedNamespace,
|
||||
}
|
||||
: {}
|
||||
),
|
||||
}),
|
||||
{ maxRetries: 0 }
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -1078,7 +1077,8 @@ describe('SavedObjectsRepository Spaces Extension', () => {
|
|||
id,
|
||||
{},
|
||||
{ upsert: true },
|
||||
{ mockGetResponseValue: { found: false } as estypes.GetResponse }
|
||||
{ mockGetResponseAsNotFound: { found: false } as estypes.GetResponse },
|
||||
[currentSpace]
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
|
@ -1354,11 +1354,12 @@ describe('SavedObjectsRepository Spaces Extension', () => {
|
|||
{
|
||||
// no namespace provided
|
||||
references: encryptedSO.references,
|
||||
}
|
||||
},
|
||||
{}
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.index).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // (no upsert) optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
|
|
|
@ -69,7 +69,6 @@ import {
|
|||
import { kibanaMigratorMock } from '../mocks';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import * as esKuery from '@kbn/es-query';
|
||||
import { errors as EsErrors } from '@elastic/elasticsearch';
|
||||
|
||||
import {
|
||||
CUSTOM_INDEX_TYPE,
|
||||
|
@ -94,8 +93,6 @@ import {
|
|||
getMockBulkCreateResponse,
|
||||
bulkGet,
|
||||
getMockBulkUpdateResponse,
|
||||
updateSuccess,
|
||||
mockUpdateResponse,
|
||||
expectErrorResult,
|
||||
expectErrorInvalidType,
|
||||
expectErrorNotFound,
|
||||
|
@ -188,6 +185,7 @@ describe('SavedObjectsRepository', () => {
|
|||
mockGetSearchDsl.mockClear();
|
||||
});
|
||||
|
||||
// Setup migration mock for creating an object
|
||||
const mockMigrationVersion = { foo: '2.3.4' };
|
||||
const mockMigrateDocument = (doc: SavedObjectUnsanitizedDoc<any>) => ({
|
||||
...doc,
|
||||
|
@ -4819,507 +4817,6 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#update', () => {
|
||||
const id = 'logstash-*';
|
||||
const type = 'index-pattern';
|
||||
const attributes = { title: 'Testing' };
|
||||
const namespace = 'foo-namespace';
|
||||
const references = [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'test',
|
||||
id: '1',
|
||||
},
|
||||
];
|
||||
const originId = 'some-origin-id';
|
||||
|
||||
beforeEach(() => {
|
||||
mockPreflightCheckForCreate.mockReset();
|
||||
mockPreflightCheckForCreate.mockImplementation(({ objects }) => {
|
||||
return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default
|
||||
});
|
||||
});
|
||||
|
||||
describe('client calls', () => {
|
||||
it(`should use the ES update action when type is not multi-namespace`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes);
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`should use the ES get action then update action when type is multi-namespace`, async () => {
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes
|
||||
);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`should check for alias conflicts if a new multi-namespace object would be created`, async () => {
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ upsert: true },
|
||||
{ mockGetResponseValue: { found: false } as estypes.GetResponse }
|
||||
);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`defaults to no references array`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes);
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: { doc: expect.not.objectContaining({ references: expect.anything() }) },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`accepts custom references array`, async () => {
|
||||
const test = async (references: SavedObjectReference[]) => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, { references });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: { doc: expect.objectContaining({ references }) },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
client.update.mockClear();
|
||||
};
|
||||
await test(references);
|
||||
await test([{ type: 'foo', id: '42', name: 'some ref' }]);
|
||||
await test([]);
|
||||
});
|
||||
|
||||
it(`uses the 'upsertAttributes' option when specified for a single-namespace type`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
upsert: {
|
||||
title: 'foo',
|
||||
description: 'bar',
|
||||
},
|
||||
});
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'index-pattern:logstash-*',
|
||||
body: expect.objectContaining({
|
||||
upsert: expect.objectContaining({
|
||||
type: 'index-pattern',
|
||||
'index-pattern': {
|
||||
title: 'foo',
|
||||
description: 'bar',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`uses the 'upsertAttributes' option when specified for a multi-namespace type that does not exist`, async () => {
|
||||
const options = { upsert: { title: 'foo', description: 'bar' } };
|
||||
mockUpdateResponse(client, MULTI_NAMESPACE_ISOLATED_TYPE, id, options);
|
||||
await repository.update(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:logstash-*`,
|
||||
body: expect.objectContaining({
|
||||
upsert: expect.objectContaining({
|
||||
type: MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
[MULTI_NAMESPACE_ISOLATED_TYPE]: {
|
||||
title: 'foo',
|
||||
description: 'bar',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`ignores use the 'upsertAttributes' option when specified for a multi-namespace type that already exists`, async () => {
|
||||
const options = { upsert: { title: 'foo', description: 'bar' } };
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
options
|
||||
);
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:logstash-*`,
|
||||
body: expect.not.objectContaining({
|
||||
upsert: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't accept custom references if not an array`, async () => {
|
||||
const test = async (references: unknown) => {
|
||||
// @ts-expect-error references is unknown
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, { references });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: { doc: expect.not.objectContaining({ references: expect.anything() }) },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
client.update.mockClear();
|
||||
};
|
||||
await test('string');
|
||||
await test(123);
|
||||
await test(true);
|
||||
await test(null);
|
||||
});
|
||||
|
||||
it(`defaults to a refresh setting of wait_for`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, { foo: 'bar' });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
refresh: 'wait_for',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`does not default to the version of the existing document when type is multi-namespace`, async () => {
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ references }
|
||||
);
|
||||
const versionProperties = {
|
||||
if_seq_no: mockVersionProps._seq_no,
|
||||
if_primary_term: mockVersionProps._primary_term,
|
||||
};
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining(versionProperties),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`accepts version`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }),
|
||||
});
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ if_seq_no: 100, if_primary_term: 200 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('default to a `retry_on_conflict` setting of `3` when `version` is not provided', async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {});
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retry_on_conflict: 3 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('default to a `retry_on_conflict` setting of `0` when `version` is provided', async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }),
|
||||
});
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retry_on_conflict: 0, if_seq_no: 100, if_primary_term: 200 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a `retryOnConflict` option', async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }),
|
||||
retryOnConflict: 42,
|
||||
});
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retry_on_conflict: 42, if_seq_no: 100, if_primary_term: 200 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, { namespace });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, { references });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
references,
|
||||
namespace: 'default',
|
||||
});
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
await updateSuccess(client, repository, registry, NAMESPACE_AGNOSTIC_TYPE, id, attributes, {
|
||||
namespace,
|
||||
});
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
client.update.mockClear();
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ namespace }
|
||||
);
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`includes _source_includes when type is multi-namespace`, async () => {
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes
|
||||
);
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`includes _source_includes when type is not multi-namespace`, async () => {
|
||||
await updateSuccess(client, repository, registry, type, id, attributes);
|
||||
expect(client.update).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
_source_includes: ['namespace', 'namespaces', 'originId'],
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
const expectNotFoundError = async (type: string, id: string) => {
|
||||
await expect(repository.update(type, id, {})).rejects.toThrowError(
|
||||
createGenericNotFoundErrorPayload(type, id)
|
||||
);
|
||||
};
|
||||
|
||||
it(`throws when options.namespace is '*'`, async () => {
|
||||
await expect(
|
||||
repository.update(type, id, attributes, { namespace: ALL_NAMESPACES_STRING })
|
||||
).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"'));
|
||||
});
|
||||
|
||||
it(`throws when type is invalid`, async () => {
|
||||
await expectNotFoundError('unknownType', id);
|
||||
expect(client.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when type is hidden`, async () => {
|
||||
await expectNotFoundError(HIDDEN_TYPE, id);
|
||||
expect(client.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when id is empty`, async () => {
|
||||
await expect(repository.update(type, '', attributes)).rejects.toThrowError(
|
||||
createBadRequestErrorPayload('id cannot be empty')
|
||||
);
|
||||
expect(client.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when ES is unable to find the document during get`, async () => {
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(
|
||||
{ found: false } as estypes.GetResponse,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`throws when ES is unable to find the index during get`, async () => {
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({} as estypes.GetResponse, {
|
||||
statusCode: 404,
|
||||
})
|
||||
);
|
||||
await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => {
|
||||
const response = getMockGetResponse(
|
||||
registry,
|
||||
{ type: MULTI_NAMESPACE_ISOLATED_TYPE, id },
|
||||
namespace
|
||||
);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`throws when there is an alias conflict from preflightCheckForCreate`, async () => {
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({
|
||||
found: false,
|
||||
} as estypes.GetResponse)
|
||||
);
|
||||
mockPreflightCheckForCreate.mockResolvedValue([
|
||||
{ type: 'type', id: 'id', error: { type: 'aliasConflict' } },
|
||||
]);
|
||||
await expect(
|
||||
repository.update(
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
{ attr: 'value' },
|
||||
{
|
||||
upsert: {
|
||||
upsertAttr: 'val',
|
||||
attr: 'value',
|
||||
},
|
||||
}
|
||||
)
|
||||
).rejects.toThrowError(createConflictErrorPayload(MULTI_NAMESPACE_ISOLATED_TYPE, id));
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`does not throw when there is a different error from preflightCheckForCreate`, async () => {
|
||||
mockPreflightCheckForCreate.mockResolvedValue([
|
||||
{ type: 'type', id: 'id', error: { type: 'conflict' } },
|
||||
]);
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes,
|
||||
{ upsert: true },
|
||||
{ mockGetResponseValue: { found: false } as estypes.GetResponse }
|
||||
);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`throws when ES is unable to find the document during update`, async () => {
|
||||
const notFoundError = new EsErrors.ResponseError(
|
||||
elasticsearchClientMock.createApiResponse({
|
||||
statusCode: 404,
|
||||
body: { error: { type: 'es_type', reason: 'es_reason' } },
|
||||
})
|
||||
);
|
||||
client.update.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createErrorTransportRequestPromise(notFoundError)
|
||||
);
|
||||
await expectNotFoundError(type, id);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns', () => {
|
||||
it(`returns _seq_no and _primary_term encoded as version`, async () => {
|
||||
const result = await updateSuccess(client, repository, registry, type, id, attributes, {
|
||||
namespace,
|
||||
references,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id,
|
||||
type,
|
||||
...mockTimestampFields,
|
||||
version: mockVersion,
|
||||
attributes,
|
||||
references,
|
||||
namespaces: [namespace],
|
||||
});
|
||||
});
|
||||
|
||||
it(`includes namespaces if type is multi-namespace`, async () => {
|
||||
const result = await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
id,
|
||||
attributes
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
namespaces: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it(`includes namespaces if type is not multi-namespace`, async () => {
|
||||
const result = await updateSuccess(client, repository, registry, type, id, attributes);
|
||||
expect(result).toMatchObject({
|
||||
namespaces: ['default'],
|
||||
});
|
||||
});
|
||||
|
||||
it(`includes originId property if present in cluster call response`, async () => {
|
||||
const result = await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
type,
|
||||
id,
|
||||
attributes,
|
||||
{},
|
||||
{ originId }
|
||||
);
|
||||
expect(result).toMatchObject({ originId });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#openPointInTimeForType', () => {
|
||||
const type = 'index-pattern';
|
||||
|
||||
|
|
|
@ -9,3 +9,4 @@
|
|||
export { decorateEsError } from './decorate_es_error';
|
||||
export { getRootFields, includedFields } from './included_fields';
|
||||
export { createRepositoryHelpers } from './create_helpers';
|
||||
export { isValidRequest } from './update_utils';
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
|
||||
|
||||
export const isValidRequest = ({
|
||||
allowedTypes,
|
||||
type,
|
||||
id,
|
||||
}: {
|
||||
allowedTypes: string[];
|
||||
type: string;
|
||||
id?: string;
|
||||
}) => {
|
||||
return !id
|
||||
? {
|
||||
validRequest: false,
|
||||
error: SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'),
|
||||
}
|
||||
: !allowedTypes.includes(type)
|
||||
? {
|
||||
validRequest: false,
|
||||
error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id),
|
||||
}
|
||||
: {
|
||||
validRequest: true,
|
||||
};
|
||||
};
|
|
@ -92,6 +92,8 @@ const createPreflightCheckHelperMock = (): PreflightCheckHelperMock => {
|
|||
preflightCheckForBulkDelete: jest.fn(),
|
||||
preflightCheckNamespaces: jest.fn(),
|
||||
preflightCheckForUpsertAliasConflict: jest.fn(),
|
||||
preflightGetDocForUpdate: jest.fn(),
|
||||
preflightCheckNamespacesForUpdate: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
|
|
|
@ -103,7 +103,7 @@ export const expectErrorConflict = (obj: TypeIdTuple, overrides?: Record<string,
|
|||
export const expectErrorInvalidType = (obj: TypeIdTuple, overrides?: Record<string, unknown>) =>
|
||||
expectErrorResult(obj, createUnsupportedTypeErrorPayload(obj.type), overrides);
|
||||
|
||||
export const KIBANA_VERSION = '2.0.0';
|
||||
export const KIBANA_VERSION = '8.8.0';
|
||||
export const ALLOWED_CONVERT_VERSION = '8.0.0';
|
||||
export const CUSTOM_INDEX_TYPE = 'customIndex';
|
||||
/** This type has namespaceType: 'agnostic'. */
|
||||
|
@ -439,7 +439,7 @@ export const getMockGetResponse = (
|
|||
}
|
||||
const namespaceId = namespaces[0] === 'default' ? undefined : namespaces[0];
|
||||
|
||||
return {
|
||||
const result = {
|
||||
// NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these
|
||||
found: true,
|
||||
_id: `${registry.isSingleNamespace(type) && namespaceId ? `${namespaceId}:` : ''}${type}:${id}`,
|
||||
|
@ -464,6 +464,7 @@ export const getMockGetResponse = (
|
|||
...mockTimestampFields,
|
||||
} as SavedObjectsRawDocSource,
|
||||
} as estypes.GetResponse<SavedObjectsRawDocSource>;
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getMockMgetResponse = (
|
||||
|
@ -489,35 +490,6 @@ expect.extend({
|
|||
},
|
||||
});
|
||||
|
||||
export const mockUpdateResponse = (
|
||||
client: ElasticsearchClientMock,
|
||||
type: string,
|
||||
id: string,
|
||||
options?: SavedObjectsUpdateOptions,
|
||||
namespaces?: string[],
|
||||
originId?: string
|
||||
) => {
|
||||
client.update.mockResponseOnce(
|
||||
{
|
||||
_id: `${type}:${id}`,
|
||||
...mockVersionProps,
|
||||
result: 'updated',
|
||||
// don't need the rest of the source for test purposes, just the namespace and namespaces attributes
|
||||
get: {
|
||||
_source: {
|
||||
namespaces: namespaces ?? [options?.namespace ?? 'default'],
|
||||
namespace: options?.namespace,
|
||||
|
||||
// If the existing saved object contains an originId attribute, the operation will return it in the result.
|
||||
// The originId parameter is just used for test purposes to modify the mock cluster call response.
|
||||
...(!!originId && { originId }),
|
||||
},
|
||||
},
|
||||
} as estypes.UpdateResponse,
|
||||
{ statusCode: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
export const updateSuccess = async <T extends Partial<unknown>>(
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
|
@ -528,20 +500,40 @@ export const updateSuccess = async <T extends Partial<unknown>>(
|
|||
options?: SavedObjectsUpdateOptions,
|
||||
internalOptions: {
|
||||
originId?: string;
|
||||
mockGetResponseValue?: estypes.GetResponse;
|
||||
mockGetResponseAsNotFound?: estypes.GetResponse;
|
||||
} = {},
|
||||
objNamespaces?: string[]
|
||||
) => {
|
||||
const { mockGetResponseValue, originId } = internalOptions;
|
||||
if (registry.isMultiNamespace(type)) {
|
||||
const mockGetResponse =
|
||||
mockGetResponseValue ??
|
||||
getMockGetResponse(registry, { type, id }, objNamespaces ?? options?.namespace);
|
||||
client.get.mockResponseOnce(mockGetResponse, { statusCode: 200 });
|
||||
const { mockGetResponseAsNotFound, originId } = internalOptions;
|
||||
const mockGetResponse =
|
||||
mockGetResponseAsNotFound ??
|
||||
getMockGetResponse(registry, { type, id, originId }, objNamespaces ?? options?.namespace);
|
||||
client.get.mockResponseOnce(mockGetResponse, { statusCode: 200 });
|
||||
if (!mockGetResponseAsNotFound) {
|
||||
// index doc from existing doc
|
||||
client.index.mockResponseImplementation((params) => {
|
||||
return {
|
||||
body: {
|
||||
_id: params.id,
|
||||
...mockVersionProps,
|
||||
} as estypes.CreateResponse,
|
||||
};
|
||||
});
|
||||
}
|
||||
mockUpdateResponse(client, type, id, options, objNamespaces, originId);
|
||||
if (mockGetResponseAsNotFound) {
|
||||
// upsert case: create the doc. (be careful here, we're also sending mockGetResponseValue as { found: false })
|
||||
client.create.mockResponseImplementation((params) => {
|
||||
return {
|
||||
body: {
|
||||
_id: params.id,
|
||||
...mockVersionProps,
|
||||
} as estypes.CreateResponse,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const result = await repository.update(type, id, attributes, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0);
|
||||
expect(client.get).toHaveBeenCalled(); // not asserting on the number of calls here, we end up testing the test mocks and not the actual implementation
|
||||
return result;
|
||||
};
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface SavedObjectsUpdateOptions<Attributes = unknown> extends SavedOb
|
|||
/**
|
||||
* An opaque version number which changes on each successful write operation.
|
||||
* Can be used for implementing optimistic concurrency control.
|
||||
* Unused for multi-namespace objects
|
||||
*/
|
||||
version?: string;
|
||||
/** {@inheritdoc SavedObjectReference} */
|
||||
|
@ -31,6 +32,8 @@ export interface SavedObjectsUpdateOptions<Attributes = unknown> extends SavedOb
|
|||
* Defaults to `0` when `version` is provided, `3` otherwise.
|
||||
*/
|
||||
retryOnConflict?: number;
|
||||
/** {@link SavedObjectsRawDocParseOptions.migrationVersionCompatibility} */
|
||||
migrationVersionCompatibility?: 'compatible' | 'raw';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -116,7 +116,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer {
|
|||
...(includeNamespaces && { namespaces }),
|
||||
...(originId && { originId }),
|
||||
attributes: _source[type],
|
||||
references: references || [],
|
||||
references: references || [], // adds references default
|
||||
...(managed != null ? { managed } : {}),
|
||||
...(migrationVersion && { migrationVersion }),
|
||||
...(coreMigrationVersion && { coreMigrationVersion }),
|
||||
|
|
|
@ -62,7 +62,12 @@ export const registerUpdateRoute = (
|
|||
});
|
||||
const { type, id } = req.params;
|
||||
const { attributes, version, references, upsert } = req.body;
|
||||
const options: SavedObjectsUpdateOptions = { version, references, upsert };
|
||||
const options: SavedObjectsUpdateOptions = {
|
||||
version,
|
||||
references,
|
||||
upsert,
|
||||
migrationVersionCompatibility: 'raw' as const,
|
||||
};
|
||||
|
||||
const usageStatsClient = coreUsageData.getClient();
|
||||
usageStatsClient.incrementSavedObjectsUpdate({ request: req }).catch(() => {});
|
||||
|
|
|
@ -73,7 +73,12 @@ export interface SavedObjectsRawDoc {
|
|||
_primary_term?: number;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Saved object document as stored in `_source` of doc in ES index
|
||||
* Similar to SavedObjectDoc and excludes `version`, includes `references`, has `attributes` in [typeMapping]
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsRawDocSource {
|
||||
type: string;
|
||||
namespace?: string;
|
||||
|
|
|
@ -24,8 +24,8 @@ import { setupConfig } from './routes_test_utils';
|
|||
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
|
||||
|
||||
const testTypes = [
|
||||
{ name: 'index-pattern', hide: false },
|
||||
{ name: 'hidden-type', hide: true },
|
||||
{ name: 'index-pattern', hide: false }, // multi-namespace type
|
||||
{ name: 'hidden-type', hide: true }, // hidden
|
||||
{ name: 'hidden-from-http', hide: false, hideFromHttpApis: true },
|
||||
];
|
||||
|
||||
|
@ -117,7 +117,7 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
|
|||
'index-pattern',
|
||||
'logstash-*',
|
||||
{ title: 'Testing' },
|
||||
{ version: 'foo' }
|
||||
{ version: 'foo', migrationVersionCompatibility: 'raw' }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ import {
|
|||
declarePostPitRoute,
|
||||
declarePostUpdateByQueryRoute,
|
||||
declarePassthroughRoute,
|
||||
declareIndexRoute,
|
||||
setProxyInterrupt,
|
||||
allCombinationsPermutations,
|
||||
} from './repository_with_proxy_utils';
|
||||
|
@ -113,6 +114,7 @@ describe('404s from proxies', () => {
|
|||
declarePostSearchRoute(hapiServer, esHostname, esPort, kbnIndexPath);
|
||||
declarePostPitRoute(hapiServer, esHostname, esPort, kbnIndexPath);
|
||||
declarePostUpdateByQueryRoute(hapiServer, esHostname, esPort, kbnIndexPath);
|
||||
declareIndexRoute(hapiServer, esHostname, esPort, kbnIndexPath);
|
||||
});
|
||||
|
||||
// register index-agnostic routes
|
||||
|
@ -396,7 +398,9 @@ describe('404s from proxies', () => {
|
|||
expect(genericNotFoundEsUnavailableError(myError, 'my_type', 'myTypeId1'));
|
||||
});
|
||||
|
||||
it('returns an EsUnavailable error on `update` requests that are interrupted', async () => {
|
||||
it('returns an EsUnavailable error on `update` requests that are interrupted during index', async () => {
|
||||
setProxyInterrupt('update');
|
||||
|
||||
let updateError;
|
||||
try {
|
||||
await repository.update('my_type', 'myTypeToUpdate', {
|
||||
|
@ -406,9 +410,26 @@ describe('404s from proxies', () => {
|
|||
} catch (err) {
|
||||
updateError = err;
|
||||
}
|
||||
|
||||
expect(genericNotFoundEsUnavailableError(updateError));
|
||||
});
|
||||
|
||||
it('returns an EsUnavailable error on `update` requests that are interrupted during preflight', async () => {
|
||||
setProxyInterrupt('updatePreflight');
|
||||
|
||||
let updateError;
|
||||
try {
|
||||
await repository.update('my_type', 'myTypeToUpdate', {
|
||||
title: 'updated title',
|
||||
});
|
||||
expect(false).toBe(true); // Should not get here (we expect the call to throw)
|
||||
} catch (err) {
|
||||
updateError = err;
|
||||
}
|
||||
|
||||
expect(genericNotFoundEsUnavailableError(updateError, 'my_type', 'myTypeToUpdate'));
|
||||
});
|
||||
|
||||
it('returns an EsUnavailable error on `bulkCreate` requests with a 404 proxy response and wrong product header', async () => {
|
||||
setProxyInterrupt('bulkCreate');
|
||||
let bulkCreateError: any;
|
||||
|
|
|
@ -27,6 +27,8 @@ export const setProxyInterrupt = (
|
|||
| 'openPit'
|
||||
| 'deleteByNamespace'
|
||||
| 'internalBulkResolve'
|
||||
| 'update'
|
||||
| 'updatePreflight'
|
||||
| null
|
||||
) => (proxyInterrupt = testArg);
|
||||
|
||||
|
@ -63,7 +65,11 @@ export const declareGetRoute = (
|
|||
path: `/${kbnIndex}/_doc/{type*}`,
|
||||
options: {
|
||||
handler: (req, h) => {
|
||||
if (req.params.type === 'my_type:myTypeId1' || req.params.type === 'my_type:myType_123') {
|
||||
if (
|
||||
req.params.type === 'my_type:myTypeId1' ||
|
||||
req.params.type === 'my_type:myType_123' ||
|
||||
proxyInterrupt === 'updatePreflight'
|
||||
) {
|
||||
return proxyResponseHandler(h, hostname, port);
|
||||
} else {
|
||||
return relayHandler(h, hostname, port);
|
||||
|
@ -257,6 +263,31 @@ export const declarePostUpdateByQueryRoute = (
|
|||
},
|
||||
});
|
||||
|
||||
// PUT _doc
|
||||
export const declareIndexRoute = (
|
||||
hapiServer: Hapi.Server,
|
||||
hostname: string,
|
||||
port: string,
|
||||
kbnIndex: string
|
||||
) =>
|
||||
hapiServer.route({
|
||||
method: ['PUT', 'POST'],
|
||||
path: `/${kbnIndex}/_doc/{_id?}`,
|
||||
options: {
|
||||
payload: {
|
||||
output: 'data',
|
||||
parse: false,
|
||||
},
|
||||
handler: (req, h) => {
|
||||
if (proxyInterrupt === 'update') {
|
||||
return proxyResponseHandler(h, hostname, port);
|
||||
} else {
|
||||
return relayHandler(h, hostname, port);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// catch-all passthrough route
|
||||
export const declarePassthroughRoute = (hapiServer: Hapi.Server, hostname: string, port: string) =>
|
||||
hapiServer.route({
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import Path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { pick } from 'lodash';
|
||||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import type { SavedObjectsType, SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server';
|
||||
import { type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server';
|
||||
import '../../migrations/jest_matchers';
|
||||
import {
|
||||
getKibanaMigratorTestKit,
|
||||
startElasticsearch,
|
||||
} from '../../migrations/kibana_migrator_test_kit';
|
||||
import { delay } from '../../migrations/test_utils';
|
||||
import { getBaseMigratorParams } from '../../migrations/fixtures/zdt_base.fixtures';
|
||||
|
||||
export const logFilePath = Path.join(__dirname, 'update.test.log');
|
||||
|
||||
describe('SOR - update API', () => {
|
||||
let esServer: TestElasticsearchUtils['es'];
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.unlink(logFilePath).catch(() => {});
|
||||
esServer = await startElasticsearch();
|
||||
});
|
||||
|
||||
const getType = (version: 'v1' | 'v2'): SavedObjectsType => {
|
||||
const versionMap: SavedObjectsModelVersionMap = {
|
||||
1: {
|
||||
changes: [],
|
||||
schemas: {
|
||||
forwardCompatibility: (attributes) => {
|
||||
return pick(attributes, 'count');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (version === 'v2') {
|
||||
versionMap[2] = {
|
||||
changes: [
|
||||
{
|
||||
type: 'data_backfill',
|
||||
backfillFn: (document) => {
|
||||
return { attributes: { even: document.attributes.count % 2 === 0 } };
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'my-test-type',
|
||||
hidden: false,
|
||||
namespaceType: 'agnostic',
|
||||
mappings: {
|
||||
dynamic: false,
|
||||
properties: {
|
||||
count: { type: 'integer' },
|
||||
...(version === 'v2' ? { even: { type: 'boolean' } } : {}),
|
||||
},
|
||||
},
|
||||
management: {
|
||||
importableAndExportable: true,
|
||||
},
|
||||
switchToModelVersionAt: '8.10.0',
|
||||
modelVersions: versionMap,
|
||||
};
|
||||
};
|
||||
|
||||
afterAll(async () => {
|
||||
await esServer?.stop();
|
||||
await delay(10);
|
||||
});
|
||||
|
||||
const setup = async () => {
|
||||
const { runMigrations: runMigrationV1, savedObjectsRepository: repositoryV1 } =
|
||||
await getKibanaMigratorTestKit({
|
||||
...getBaseMigratorParams(),
|
||||
types: [getType('v1')],
|
||||
});
|
||||
await runMigrationV1();
|
||||
|
||||
const {
|
||||
runMigrations: runMigrationV2,
|
||||
savedObjectsRepository: repositoryV2,
|
||||
client: esClient,
|
||||
} = await getKibanaMigratorTestKit({
|
||||
...getBaseMigratorParams(),
|
||||
types: [getType('v2')],
|
||||
});
|
||||
await runMigrationV2();
|
||||
|
||||
return { repositoryV1, repositoryV2, esClient };
|
||||
};
|
||||
|
||||
it('supports updates between older and newer versions', async () => {
|
||||
const { repositoryV1, repositoryV2, esClient } = await setup();
|
||||
|
||||
await repositoryV1.create('my-test-type', { count: 12 }, { id: 'my-id' });
|
||||
|
||||
let document = await repositoryV2.get('my-test-type', 'my-id');
|
||||
|
||||
expect(document.attributes).toEqual({
|
||||
count: 12,
|
||||
even: true,
|
||||
});
|
||||
|
||||
await repositoryV2.update('my-test-type', 'my-id', {
|
||||
count: 11,
|
||||
even: false,
|
||||
});
|
||||
|
||||
document = await repositoryV1.get('my-test-type', 'my-id');
|
||||
|
||||
expect(document.attributes).toEqual({
|
||||
count: 11,
|
||||
});
|
||||
|
||||
await repositoryV1.update('my-test-type', 'my-id', {
|
||||
count: 14,
|
||||
});
|
||||
|
||||
document = await repositoryV2.get('my-test-type', 'my-id');
|
||||
|
||||
expect(document.attributes).toEqual({
|
||||
count: 14,
|
||||
even: true,
|
||||
});
|
||||
|
||||
const rawDoc = await fetchDoc(esClient, 'my-test-type', 'my-id');
|
||||
expect(rawDoc._source).toEqual(
|
||||
expect.objectContaining({
|
||||
typeMigrationVersion: '10.1.0',
|
||||
'my-test-type': {
|
||||
count: 14,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const fetchDoc = async (client: ElasticsearchClient, type: string, id: string) => {
|
||||
return await client.get({
|
||||
index: '.kibana',
|
||||
id: `${type}:${id}`,
|
||||
});
|
||||
};
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue