Support generating legacy URL aliases for objects that change IDs during import. (#149021)

This commit is contained in:
Aleh Zasypkin 2023-04-03 10:54:23 +02:00 committed by GitHub
parent b15737a7f0
commit a1fccfd880
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1452 additions and 108 deletions

View file

@ -40,13 +40,19 @@ Saved objects can only be imported into the same version, a newer minor on the s
(Optional, boolean) Creates copies of saved objects, regenerates each object ID, and resets the origin. When used, potential conflict
errors are avoided.
+
NOTE: This cannot be used with the `overwrite` option.
NOTE: This option cannot be used with the `overwrite` and `compatibilityMode` options.
`overwrite`::
(Optional, boolean) Overwrites saved objects when they already exist. When used, potential conflict errors are automatically resolved by
overwriting the destination object.
+
NOTE: This cannot be used with the `createNewCopies` option.
NOTE: This option cannot be used with the `createNewCopies` option.
`compatibilityMode`::
(Optional, boolean) Applies various adjustments to the saved objects that are being imported to maintain compatibility between different {kib}
versions. Use this option only if you encounter issues with imported saved objects.
+
NOTE: This option cannot be used with the `createNewCopies` option.
[[saved-objects-api-import-request-body]]
==== Request body

View file

@ -55,13 +55,19 @@ You can request to overwrite any objects that already exist in the target space
(Optional, boolean) Creates new copies of saved objects, regenerates each object ID, and resets the origin. When used, potential conflict
errors are avoided. The default value is `true`.
+
NOTE: This cannot be used with the `overwrite` option.
NOTE: This option cannot be used with the `overwrite` and `compatibilityMode` options.
`overwrite`::
(Optional, boolean) When set to `true`, all conflicts are automatically overridden. When a saved object with a matching `type` and `id`
exists in the target space, that version is replaced with the version from the source space. The default value is `false`.
+
NOTE: This cannot be used with the `createNewCopies` option.
NOTE: This option cannot be used with the `createNewCopies` option.
`compatibilityMode`::
(Optional, boolean) Applies various adjustments to the saved objects that are being copied to maintain compatibility between different {kib}
versions. Use this option only if you encounter issues with copied saved objects.
+
NOTE: This option cannot be used with the `createNewCopies` option.
[role="child_attributes"]
[[spaces-api-copy-saved-objects-response-body]]

View file

@ -878,6 +878,14 @@ describe('SavedObjectsRepository Spaces Extension', () => {
);
});
});
test('#getCurrentNamespace', () => {
mockSpacesExt.getCurrentNamespace.mockReturnValue('ns-from-ext');
expect(repository.getCurrentNamespace('ns-from-arg')).toBe('ns-from-ext');
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith('ns-from-arg');
});
});
});

View file

@ -5228,4 +5228,24 @@ describe('SavedObjectsRepository', () => {
await expect(repository.updateObjectsSpaces([], [], [])).rejects.toEqual(expectedResult);
});
});
describe('#getCurrentNamespace', () => {
it('returns `undefined` for `undefined` namespace argument', async () => {
expect(repository.getCurrentNamespace()).toBeUndefined();
});
it('throws if `*` namespace argument is provided', async () => {
expect(() => repository.getCurrentNamespace('*')).toThrowErrorMatchingInlineSnapshot(
`"\\"options.namespace\\" cannot be \\"*\\": Bad Request"`
);
});
it('properly handles `default` namespace', async () => {
expect(repository.getCurrentNamespace('default')).toBeUndefined();
});
it('properly handles non-`default` namespace', async () => {
expect(repository.getCurrentNamespace('space-a')).toBe('space-a');
});
});
});

View file

@ -2553,6 +2553,16 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
});
}
/**
* {@inheritDoc ISavedObjectsRepository.getCurrentNamespace}
*/
getCurrentNamespace(namespace?: string) {
if (this._spacesExtension) {
return this._spacesExtension.getCurrentNamespace(namespace);
}
return normalizeNamespace(namespace);
}
/**
* Returns index specified by the given type or the default index
*
@ -2664,20 +2674,6 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
// any other error from this check does not matter
}
/**
* If the spaces extension is enabled, we should use that to get the current namespace (and optionally throw an error if a consumer
* attempted to specify the namespace option).
*
* If the spaces extension is *not* enabled, we should simply normalize the namespace option so that `'default'` can be used
* interchangeably with `undefined`.
*/
private getCurrentNamespace(namespace?: string) {
if (this._spacesExtension) {
return this._spacesExtension.getCurrentNamespace(namespace);
}
return normalizeNamespace(namespace);
}
/** The `initialNamespaces` field (create, bulkCreate) is used to create an object in an initial set of spaces. */
private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) {
if (!initialNamespaces) {

View file

@ -32,6 +32,7 @@ const createRepositoryMock = () => {
removeReferencesTo: jest.fn(),
collectMultiNamespaceReferences: jest.fn(),
updateObjectsSpaces: jest.fn(),
getCurrentNamespace: jest.fn(),
};
return mock;

View file

@ -326,4 +326,12 @@ describe('SavedObjectsClient', () => {
);
expect(result).toBe(returnValue);
});
test(`#getCurrentNamespace`, () => {
mockRepository.getCurrentNamespace.mockReturnValue('ns');
const client = new SavedObjectsClient(mockRepository);
expect(client.getCurrentNamespace()).toEqual('ns');
expect(mockRepository.getCurrentNamespace).toHaveBeenCalledWith();
});
});

View file

@ -208,4 +208,9 @@ export class SavedObjectsClient implements SavedObjectsClientContract {
options
);
}
/** {@inheritDoc SavedObjectsClientContract.getCurrentNamespace} */
getCurrentNamespace() {
return this._repository.getCurrentNamespace();
}
}

View file

@ -31,6 +31,7 @@ const create = () => {
removeReferencesTo: jest.fn(),
collectMultiNamespaceReferences: jest.fn(),
updateObjectsSpaces: jest.fn(),
getCurrentNamespace: jest.fn(),
};
mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({

View file

@ -31,6 +31,7 @@ const create = () => {
removeReferencesTo: jest.fn(),
collectMultiNamespaceReferences: jest.fn(),
updateObjectsSpaces: jest.fn(),
getCurrentNamespace: jest.fn(),
} as unknown as jest.Mocked<SavedObjectsClientContract>;
mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({

View file

@ -47,7 +47,7 @@ import type {
} from './apis';
/**
* Saved Objects is Kibana's data persisentence mechanism allowing plugins to
* Saved Objects is Kibana's data persistence mechanism allowing plugins to
* use Elasticsearch for storing plugin state.
*
* ## SavedObjectsClient errors
@ -288,7 +288,7 @@ export interface SavedObjectsClientContract {
*
* @param type - the type of the object to remove references to
* @param id - the ID of the object to remove references to
* @param options {@link SavedObjectsRemoveReferencesToOptions} - options for the remove references opertion
* @param options {@link SavedObjectsRemoveReferencesToOptions} - options for the remove references operation
* @returns the {@link SavedObjectsRemoveReferencesToResponse}
*/
removeReferencesTo(
@ -377,7 +377,7 @@ export interface SavedObjectsClientContract {
* ```
*
* @param findOptions {@link SavedObjectsCreatePointInTimeFinderOptions} - options for the create PIT finder operation
* @param dependencies {@link SavedObjectsCreatePointInTimeFinderDependencies} - dependencies for the create PIT fimder operation
* @param dependencies {@link SavedObjectsCreatePointInTimeFinderDependencies} - dependencies for the create PIT finder operation
* @returns the created PIT finder
*/
createPointInTimeFinder<T = unknown, A = unknown>(
@ -412,4 +412,9 @@ export interface SavedObjectsClientContract {
spacesToRemove: string[],
options?: SavedObjectsUpdateObjectsSpacesOptions
): Promise<SavedObjectsUpdateObjectsSpacesResponse>;
/**
* Returns the namespace associated with the client. If the namespace is the default one, this method returns `undefined`.
*/
getCurrentNamespace(): string | undefined;
}

View file

@ -533,4 +533,14 @@ export interface ISavedObjectsRepository {
findOptions: SavedObjectsCreatePointInTimeFinderOptions,
dependencies?: SavedObjectsCreatePointInTimeFinderDependencies
): ISavedObjectsPointInTimeFinder<T, A>;
/**
* If the spaces extension is enabled, it's used to get the current namespace (and optionally throws an error if a
* consumer attempted to specify the namespace explicitly).
*
* If the spaces extension is *not* enabled, this function simply normalizes the specified namespace so that
* `'default'` can be used interchangeably with `undefined` i.e. the method always returns `undefined` for the default
* namespace.
*/
getCurrentNamespace(namespace?: string): string | undefined;
}

View file

@ -49,6 +49,11 @@ export interface ImportSavedObjectsOptions {
namespace?: string;
/** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */
createNewCopies: boolean;
/**
* If true, Kibana will apply various adjustments to the data that's being imported to maintain compatibility between
* different Kibana versions (e.g. generate legacy URL aliases for all imported objects that have to change IDs).
*/
compatibilityMode?: boolean;
}
/**
@ -67,6 +72,7 @@ export async function importSavedObjectsFromStream({
importHooks,
namespace,
refresh,
compatibilityMode,
}: ImportSavedObjectsOptions): Promise<SavedObjectsImportResponse> {
let errorAccumulator: SavedObjectsImportFailure[] = [];
const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name);
@ -147,6 +153,7 @@ export async function importSavedObjectsFromStream({
overwrite,
namespace,
refresh,
compatibilityMode,
};
const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams);
errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors];

View file

@ -12,6 +12,10 @@ import { type SavedObject, SavedObjectsErrorHelpers } from '@kbn/core-saved-obje
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { createSavedObjects } from './create_saved_objects';
import { extractErrors } from './extract_errors';
import {
LEGACY_URL_ALIAS_TYPE,
LegacyUrlAlias,
} from '@kbn/core-saved-objects-base-server-internal';
type CreateSavedObjectsParams = Parameters<typeof createSavedObjects>[0];
@ -30,6 +34,18 @@ const createObject = (type: string, id: string, originId?: string): SavedObject
...(originId && { originId }),
});
const createLegacyUrlAliasObject = (
sourceId: string,
targetId: string,
targetType: string,
targetNamespace: string = 'default'
): SavedObject<LegacyUrlAlias> => ({
type: LEGACY_URL_ALIAS_TYPE,
id: `${targetNamespace}:${targetType}:${sourceId}`,
attributes: { sourceId, targetNamespace, targetType, targetId, purpose: 'savedObjectImport' },
references: [],
});
const MULTI_NS_TYPE = 'multi';
const OTHER_TYPE = 'other';
/**
@ -59,6 +75,9 @@ const importStateMap = new Map([
[`${obj8.type}:${obj8.id}`, { destinationId: importId8 }],
]);
const legacyUrlAliasForObj1 = createLegacyUrlAliasObject(obj1.originId!, obj1.id, obj1.type);
const legacyUrlAliasForObj10 = createLegacyUrlAliasObject(obj10.originId!, obj10.id, obj10.type);
describe('#createSavedObjects', () => {
let savedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
let bulkCreate: typeof savedObjectsClient['bulkCreate'];
@ -72,6 +91,7 @@ describe('#createSavedObjects', () => {
accumulatedErrors?: SavedObjectsImportFailure[];
namespace?: string;
overwrite?: boolean;
compatibilityMode?: boolean;
}): CreateSavedObjectsParams => {
savedObjectsClient = savedObjectsClientMock.create();
bulkCreate = savedObjectsClient.bulkCreate;
@ -98,6 +118,10 @@ describe('#createSavedObjects', () => {
const expectedOptions = expect.any(Object);
expect(bulkCreate).toHaveBeenNthCalledWith(n, expectedObjects, expectedOptions);
},
legacyUrlAliases: (n: number, expectedAliasObjects: SavedObject[]) => {
const expectedOptions = expect.any(Object);
expect(bulkCreate).toHaveBeenNthCalledWith(n, expectedAliasObjects, expectedOptions);
},
options: (n: number, options: CreateSavedObjectsParams) => {
const expectedObjects = expect.any(Array);
const expectedOptions = { namespace: options.namespace, overwrite: options.overwrite };
@ -143,7 +167,7 @@ describe('#createSavedObjects', () => {
const remappedResults = resultObjects.map((result, i) => ({ ...result, id: objects[i].id }));
return {
createdObjects: remappedResults.filter((obj) => !obj.error),
errors: extractErrors(remappedResults, objects),
errors: extractErrors(remappedResults, objects, [], new Map()),
};
};
@ -217,6 +241,18 @@ describe('#createSavedObjects', () => {
}
});
test('when in compatibility mode, does not call bulkCreate when resolvable errors are present', async () => {
for (const error of resolvableErrors) {
const options = setupParams({
objects: objs,
accumulatedErrors: [error],
compatibilityMode: true,
});
await createSavedObjects(options);
expect(bulkCreate).not.toHaveBeenCalled();
}
});
test('calls bulkCreate when unresolvable errors or no errors are present', async () => {
for (const error of unresolvableErrors) {
const options = setupParams({ objects: objs, accumulatedErrors: [error] });
@ -230,6 +266,26 @@ describe('#createSavedObjects', () => {
await createSavedObjects(options);
expect(bulkCreate).toHaveBeenCalledTimes(1);
});
test('when in compatibility mode, calls bulkCreate for legacy URL aliases when unresolvable errors or no errors are present', async () => {
for (const error of unresolvableErrors) {
const options = setupParams({
objects: objs,
accumulatedErrors: [error],
compatibilityMode: true,
});
setupMockResults(options);
await createSavedObjects(options);
expect(bulkCreate).toHaveBeenCalledTimes(2);
expectBulkCreateArgs.legacyUrlAliases(2, [legacyUrlAliasForObj1, legacyUrlAliasForObj10]);
bulkCreate.mockClear();
}
const options = setupParams({ objects: objs, compatibilityMode: true });
setupMockResults(options);
await createSavedObjects(options);
expect(bulkCreate).toHaveBeenCalledTimes(2);
expectBulkCreateArgs.legacyUrlAliases(2, [legacyUrlAliasForObj1, legacyUrlAliasForObj10]);
});
});
it('filters out version from objects before create', async () => {
@ -240,30 +296,57 @@ describe('#createSavedObjects', () => {
expectBulkCreateArgs.objects(1, [obj1]);
});
const testBulkCreateObjects = async (namespace?: string) => {
const options = setupParams({ objects: objs, namespace });
const testBulkCreateObjects = async ({
namespace,
compatibilityMode,
}: { namespace?: string; compatibilityMode?: boolean } = {}) => {
const options = setupParams({ objects: objs, namespace, compatibilityMode });
setupMockResults(options);
await createSavedObjects(options);
expect(bulkCreate).toHaveBeenCalledTimes(1);
expect(bulkCreate).toHaveBeenCalledTimes(compatibilityMode ? 2 : 1);
// these three objects are transformed before being created, because they are included in the `importStateMap`
const x3 = { ...obj3, id: importId3, originId: undefined }; // this import object already has an originId, but the entry has omitOriginId=true
const x4 = { ...obj4, id: importId4 }; // this import object already has an originId
const x8 = { ...obj8, id: importId8, originId: obj8.id }; // this import object doesn't have an originId, so it is set before create
const argObjs = [obj1, obj2, x3, x4, obj5, obj6, obj7, x8, obj9, obj10, obj11, obj12, obj13];
expectBulkCreateArgs.objects(1, argObjs);
if (compatibilityMode) {
// Rewrite namespace in the legacy URL alias.
const argLegacyUrlAliasObjs = namespace
? [legacyUrlAliasForObj1, legacyUrlAliasForObj10].map((legacyUrlAlias) =>
createLegacyUrlAliasObject(
legacyUrlAlias.attributes.sourceId,
legacyUrlAlias.attributes.targetId,
legacyUrlAlias.attributes.targetType,
namespace
)
)
: [legacyUrlAliasForObj1, legacyUrlAliasForObj10];
expectBulkCreateArgs.legacyUrlAliases(2, argLegacyUrlAliasObjs);
}
};
const testBulkCreateOptions = async (namespace?: string) => {
const testBulkCreateOptions = async ({
namespace,
compatibilityMode,
}: { namespace?: string; compatibilityMode?: boolean } = {}) => {
const overwrite = Symbol() as unknown as boolean;
const options = setupParams({ objects: objs, namespace, overwrite });
const options = setupParams({ objects: objs, namespace, overwrite, compatibilityMode });
setupMockResults(options);
await createSavedObjects(options);
expect(bulkCreate).toHaveBeenCalledTimes(1);
expect(bulkCreate).toHaveBeenCalledTimes(compatibilityMode ? 2 : 1);
expectBulkCreateArgs.options(1, options);
if (compatibilityMode) {
expectBulkCreateArgs.options(2, options);
}
};
const testReturnValue = async (namespace?: string) => {
const options = setupParams({ objects: objs, namespace });
const testReturnValue = async ({
namespace,
compatibilityMode,
}: { namespace?: string; compatibilityMode?: boolean } = {}) => {
const options = setupParams({ objects: objs, namespace, compatibilityMode });
setupMockResults(options);
const results = await createSavedObjects(options);
@ -277,27 +360,33 @@ describe('#createSavedObjects', () => {
};
describe('with an undefined namespace', () => {
test('calls bulkCreate once with input objects', async () => {
test('calls bulkCreate according to input objects and compatibilityMode option', async () => {
await testBulkCreateObjects();
await testBulkCreateObjects({ compatibilityMode: true });
});
test('calls bulkCreate once with input options', async () => {
await testBulkCreateOptions();
await testBulkCreateOptions({ compatibilityMode: true });
});
test('returns bulkCreate results that are remapped to IDs of imported objects', async () => {
await testReturnValue();
await testReturnValue({ compatibilityMode: true });
});
});
describe('with a defined namespace', () => {
const namespace = 'some-namespace';
test('calls bulkCreate once with input objects', async () => {
await testBulkCreateObjects(namespace);
test('calls bulkCreate according to input objects and compatibilityMode option', async () => {
await testBulkCreateObjects({ namespace });
await testBulkCreateObjects({ namespace, compatibilityMode: true });
});
test('calls bulkCreate once with input options', async () => {
await testBulkCreateOptions(namespace);
await testBulkCreateOptions({ namespace });
await testBulkCreateOptions({ namespace, compatibilityMode: true });
});
test('returns bulkCreate results that are remapped to IDs of imported objects', async () => {
await testReturnValue(namespace);
await testReturnValue({ namespace });
await testReturnValue({ namespace, compatibilityMode: true });
});
});
});

View file

@ -9,6 +9,11 @@
import type { SavedObjectsImportFailure } from '@kbn/core-saved-objects-common';
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import type { CreatedObject, SavedObject } from '@kbn/core-saved-objects-server';
import {
LEGACY_URL_ALIAS_TYPE,
LegacyUrlAlias,
} from '@kbn/core-saved-objects-base-server-internal';
import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server';
import { extractErrors } from './extract_errors';
import type { ImportStateMap } from './types';
@ -20,6 +25,11 @@ export interface CreateSavedObjectsParams<T> {
namespace?: string;
overwrite?: boolean;
refresh?: boolean | 'wait_for';
/**
* If true, Kibana will apply various adjustments to the data that's being imported to maintain compatibility between
* different Kibana versions (e.g. generate legacy URL aliases for all imported objects that have to change IDs).
*/
compatibilityMode?: boolean;
}
export interface CreateSavedObjectsResult<T> {
@ -39,6 +49,7 @@ export const createSavedObjects = async <T>({
namespace,
overwrite,
refresh,
compatibilityMode,
}: CreateSavedObjectsParams<T>): Promise<CreateSavedObjectsResult<T>> => {
// filter out any objects that resulted in errors
const errorSet = accumulatedErrors.reduce(
@ -89,8 +100,12 @@ export const createSavedObjects = async <T>({
});
const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references'];
let expectedResults = objectsToCreate;
if (!accumulatedErrors.some(({ error: { type } }) => resolvableErrors.includes(type))) {
const hasResolvableErrors = accumulatedErrors.some(({ error: { type } }) =>
resolvableErrors.includes(type)
);
let expectedResults: Array<SavedObject<T>> = objectsToCreate;
if (!hasResolvableErrors) {
const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, {
namespace,
overwrite,
@ -99,16 +114,60 @@ export const createSavedObjects = async <T>({
expectedResults = bulkCreateResponse.saved_objects;
}
// remap results to reflect the object IDs that were submitted for import
// this ensures that consumers understand the results
const remappedResults = expectedResults.map<CreatedObject<T>>((result) => {
// Namespace to use as the target namespace for the legacy URLs we create when in compatibility mode. If the namespace
// is specified explicitly we should use it instead of the namespace the saved objects client is scoped to. In certain
// scenarios (e.g. copying to a default space) both current namespace and namespace from the parameter aren't defined.
const legacyUrlTargetNamespace = SavedObjectsUtils.namespaceIdToString(
namespace ?? savedObjectsClient.getCurrentNamespace()
);
// Remap results to reflect the object IDs that were submitted for import this ensures that consumers understand the
// results, and collect legacy URL aliases if in compatibility mode.
const remappedResults: Array<CreatedObject<T>> = [];
const legacyUrlAliases = new Map<string, SavedObject<LegacyUrlAlias>>();
for (const result of expectedResults) {
const { id } = objectIdMap.get(`${result.type}:${result.id}`)!;
// also, include a `destinationId` field if the object create attempt was made with a different ID
return { ...result, id, ...(id !== result.id && { destinationId: result.id }) };
});
remappedResults.push({ ...result, id, ...(id !== result.id && { destinationId: result.id }) });
// Indicates that object has been successfully imported.
const objectSuccessfullyImported = !hasResolvableErrors && !result.error;
// Indicates that the object has changed ID at some point with the original ID retained as the origin ID, so that
// legacy URL alias is required to retrieve the object using its original ID.
const objectRequiresLegacyUrlAlias = !!result.originId && result.originId !== result.id;
if (compatibilityMode && objectRequiresLegacyUrlAlias && objectSuccessfullyImported) {
const legacyUrlAliasId = `${legacyUrlTargetNamespace}:${result.type}:${result.originId}`;
legacyUrlAliases.set(legacyUrlAliasId, {
id: legacyUrlAliasId,
type: LEGACY_URL_ALIAS_TYPE,
references: [],
attributes: {
// We can safely force `originId` here since it's enforced by `objectRequiresLegacyUrlAlias`.
sourceId: result.originId!,
targetNamespace: legacyUrlTargetNamespace,
targetType: result.type,
targetId: result.id,
purpose: 'savedObjectImport',
},
});
}
}
// Create legacy URL aliases if needed.
const legacyUrlAliasResults =
legacyUrlAliases.size > 0
? (
await savedObjectsClient.bulkCreate([...legacyUrlAliases.values()], {
namespace,
overwrite,
refresh,
})
).saved_objects
: [];
return {
createdObjects: remappedResults.filter((obj) => !obj.error),
errors: extractErrors(remappedResults, objects),
errors: extractErrors(remappedResults, objects, legacyUrlAliasResults, legacyUrlAliases),
};
};

View file

@ -12,11 +12,15 @@ import {
SavedObjectsErrorHelpers,
} from '@kbn/core-saved-objects-server';
import { extractErrors } from './extract_errors';
import {
LEGACY_URL_ALIAS_TYPE,
LegacyUrlAlias,
} from '@kbn/core-saved-objects-base-server-internal';
describe('extractErrors()', () => {
test('returns empty array when no errors exist', () => {
const savedObjects: SavedObject[] = [];
const result = extractErrors(savedObjects, savedObjects);
const result = extractErrors(savedObjects, savedObjects, [], new Map());
expect(result).toMatchInlineSnapshot(`Array []`);
});
@ -51,7 +55,7 @@ describe('extractErrors()', () => {
destinationId: 'foo',
},
];
const result = extractErrors(savedObjects, savedObjects);
const result = extractErrors(savedObjects, savedObjects, [], new Map());
expect(result).toMatchInlineSnapshot(`
Array [
Object {
@ -91,4 +95,106 @@ describe('extractErrors()', () => {
]
`);
});
test('extracts errors from legacy URL alias saved objects', () => {
const savedObjects: Array<CreatedObject<unknown>> = [
{
id: '1',
type: 'dashboard',
attributes: { title: 'My Dashboard 1' },
references: [],
destinationId: 'one',
},
{
id: '2',
type: 'dashboard',
attributes: { title: 'My Dashboard 2' },
references: [],
error: SavedObjectsErrorHelpers.createConflictError('dashboard', '2').output.payload,
},
{
id: '3',
type: 'dashboard',
attributes: { title: 'My Dashboard 3' },
references: [],
destinationId: 'three',
},
];
const legacyUrlAliasSavedObjects = new Map<string, SavedObject<LegacyUrlAlias>>([
[
'default:dashboard:1',
{
type: LEGACY_URL_ALIAS_TYPE,
id: 'default:dashboard:1',
attributes: {
sourceId: '1',
targetNamespace: 'default',
targetType: 'dashboard',
targetId: 'one',
purpose: 'savedObjectImport',
},
references: [],
},
],
[
'default:dashboard:3',
{
type: LEGACY_URL_ALIAS_TYPE,
id: 'default:dashboard:3',
attributes: {
sourceId: '3',
targetNamespace: 'default',
targetType: 'dashboard',
targetId: 'three',
purpose: 'savedObjectImport',
},
references: [],
},
],
]);
const legacyUrlAliasResults = [
{ type: LEGACY_URL_ALIAS_TYPE, id: 'default:dashboard:1', attributes: {}, references: [] },
{
type: LEGACY_URL_ALIAS_TYPE,
id: 'default:dashboard:3',
attributes: {},
references: [],
error: SavedObjectsErrorHelpers.createConflictError('dashboard', '3').output.payload,
},
];
const result = extractErrors(
savedObjects,
savedObjects,
legacyUrlAliasResults,
legacyUrlAliasSavedObjects
);
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"error": Object {
"type": "conflict",
},
"id": "2",
"meta": Object {
"title": "My Dashboard 2",
},
"type": "dashboard",
},
Object {
"error": Object {
"error": "Conflict",
"message": "Saved object [dashboard/3] conflict",
"statusCode": 409,
"type": "unknown",
},
"id": "default:dashboard:3",
"meta": Object {
"title": "Legacy URL alias (3 -> three)",
},
"type": "legacy-url-alias",
},
]
`);
});
});

View file

@ -8,11 +8,14 @@
import type { SavedObjectsImportFailure } from '@kbn/core-saved-objects-common';
import type { CreatedObject, SavedObject } from '@kbn/core-saved-objects-server';
import type { LegacyUrlAlias } from '@kbn/core-saved-objects-base-server-internal';
export function extractErrors(
// TODO: define saved object type
savedObjectResults: Array<CreatedObject<unknown>>,
savedObjectsToImport: Array<SavedObject<any>>
savedObjectsToImport: Array<SavedObject<any>>,
legacyUrlAliasResults: SavedObject[],
legacyUrlAliasesToCreate: Map<string, SavedObject<LegacyUrlAlias>>
) {
const errors: SavedObjectsImportFailure[] = [];
const originalSavedObjectsMap = new Map<string, SavedObject<{ title: string }>>();
@ -49,5 +52,27 @@ export function extractErrors(
});
}
}
for (const legacyUrlAliasResult of legacyUrlAliasResults) {
if (!legacyUrlAliasResult.error) {
continue;
}
const legacyUrlAlias = legacyUrlAliasesToCreate.get(legacyUrlAliasResult.id);
if (legacyUrlAlias) {
errors.push({
id: legacyUrlAlias.id,
type: legacyUrlAlias.type,
meta: {
title: `Legacy URL alias (${legacyUrlAlias.attributes.sourceId} -> ${legacyUrlAlias.attributes.targetId})`,
},
error: {
...legacyUrlAliasResult.error,
type: 'unknown',
},
});
}
}
return errors;
}

View file

@ -85,6 +85,7 @@ describe('#importSavedObjectsFromStream', () => {
const setupOptions = ({
retries = [],
createNewCopies = false,
compatibilityMode,
getTypeImpl = (type: string) =>
({
// other attributes aren't needed for the purposes of injecting metadata
@ -94,6 +95,7 @@ describe('#importSavedObjectsFromStream', () => {
}: {
retries?: SavedObjectsImportRetry[];
createNewCopies?: boolean;
compatibilityMode?: boolean;
getTypeImpl?: (name: string) => any;
importHooks?: Record<string, SavedObjectsImportHook[]>;
} = {}): ResolveSavedObjectsImportErrorsOptions => {
@ -112,6 +114,7 @@ describe('#importSavedObjectsFromStream', () => {
// namespace and createNewCopies don't matter, as they don't change the logic in this module, they just get passed to sub-module methods
namespace,
createNewCopies,
compatibilityMode,
};
};
@ -460,6 +463,34 @@ describe('#importSavedObjectsFromStream', () => {
objects: objectsToNotOverwrite,
});
});
test('applies `compatibilityMode` if specified', async () => {
const objectsToOverwrite = [createObject()];
const objectsToNotOverwrite = [createObject()];
mockSplitOverwrites.mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite });
mockCreateSavedObjects.mockResolvedValueOnce({
errors: [createError()], // this error will NOT be passed to the second `mockCreateSavedObjects` call
createdObjects: [],
});
await resolveSavedObjectsImportErrors(setupOptions({ compatibilityMode: true }));
const partialCreateSavedObjectsParams = {
accumulatedErrors: [],
savedObjectsClient,
importStateMap: new Map(),
namespace,
compatibilityMode: true,
};
expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(1, {
...partialCreateSavedObjectsParams,
objects: objectsToOverwrite,
overwrite: true,
});
expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(2, {
...partialCreateSavedObjectsParams,
objects: objectsToNotOverwrite,
});
});
});
describe('with createNewCopies enabled', () => {

View file

@ -55,6 +55,11 @@ export interface ResolveSavedObjectsImportErrorsOptions {
namespace?: string;
/** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */
createNewCopies: boolean;
/**
* If true, Kibana will apply various adjustments to the data that's being retried to import to maintain compatibility between
* different Kibana versions (e.g. generate legacy URL aliases for all imported objects that have to change IDs).
*/
compatibilityMode?: boolean;
}
/**
@ -72,6 +77,7 @@ export async function resolveSavedObjectsImportErrors({
importHooks,
namespace,
createNewCopies,
compatibilityMode,
}: ResolveSavedObjectsImportErrorsOptions): Promise<SavedObjectsImportResponse> {
// throw a BadRequest error if we see invalid retries
validateRetries(retries);
@ -205,6 +211,7 @@ export async function resolveSavedObjectsImportErrors({
importStateMap,
namespace,
overwrite,
compatibilityMode,
};
const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects(
createSavedObjectsParams

View file

@ -56,6 +56,7 @@ export class SavedObjectsImporter implements ISavedObjectsImporter {
namespace,
overwrite,
refresh,
compatibilityMode,
}: SavedObjectsImportOptions): Promise<SavedObjectsImportResponse> {
return importSavedObjectsFromStream({
readStream,
@ -63,6 +64,7 @@ export class SavedObjectsImporter implements ISavedObjectsImporter {
namespace,
overwrite,
refresh,
compatibilityMode,
objectLimit: this.#importSizeLimit,
savedObjectsClient: this.#savedObjectsClient,
typeRegistry: this.#typeRegistry,
@ -73,12 +75,14 @@ export class SavedObjectsImporter implements ISavedObjectsImporter {
public resolveImportErrors({
readStream,
createNewCopies,
compatibilityMode,
namespace,
retries,
}: SavedObjectsResolveImportErrorsOptions): Promise<SavedObjectsImportResponse> {
return resolveSavedObjectsImportErrors({
readStream,
createNewCopies,
compatibilityMode,
namespace,
retries,
objectLimit: this.#importSizeLimit,

View file

@ -18,11 +18,12 @@
"@kbn/core-saved-objects-base-server-internal",
"@kbn/core-saved-objects-api-server",
"@kbn/core-saved-objects-server",
"@kbn/core-saved-objects-utils-server",
"@kbn/core-http-server-mocks",
"@kbn/core-saved-objects-api-server-mocks",
"@kbn/logging-mocks",
"@kbn/core-saved-objects-base-server-mocks",
"@kbn/core-http-router-server-internal",
"@kbn/core-http-router-server-internal"
],
"exclude": [
"target/**/*",

View file

@ -47,12 +47,17 @@ export const registerImportRoute = (
{
overwrite: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: false }),
compatibilityMode: schema.boolean({ defaultValue: false }),
},
{
validate: (object) => {
if (object.overwrite && object.createNewCopies) {
return 'cannot use [overwrite] with [createNewCopies]';
}
if (object.createNewCopies && object.compatibilityMode) {
return 'cannot use [createNewCopies] with [compatibilityMode]';
}
},
}
),
@ -62,12 +67,17 @@ export const registerImportRoute = (
},
},
catchAndReturnBoomErrors(async (context, req, res) => {
const { overwrite, createNewCopies } = req.query;
const { overwrite, createNewCopies, compatibilityMode } = req.query;
const { getClient, getImporter, typeRegistry } = (await context.core).savedObjects;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient
.incrementSavedObjectsImport({ request: req, createNewCopies, overwrite })
.incrementSavedObjectsImport({
request: req,
createNewCopies,
overwrite,
compatibilityMode,
})
.catch(() => {});
const file = req.body.file as FileStream;
@ -99,6 +109,7 @@ export const registerImportRoute = (
readStream,
overwrite,
createNewCopies,
compatibilityMode,
});
return res.ok({ body: result });

View file

@ -44,9 +44,19 @@ export const registerResolveImportErrorsRoute = (
},
},
validate: {
query: schema.object({
createNewCopies: schema.boolean({ defaultValue: false }),
}),
query: schema.object(
{
createNewCopies: schema.boolean({ defaultValue: false }),
compatibilityMode: schema.boolean({ defaultValue: false }),
},
{
validate: (object) => {
if (object.createNewCopies && object.compatibilityMode) {
return 'cannot use [createNewCopies] with [compatibilityMode]';
}
},
}
),
body: schema.object({
file: schema.stream(),
retries: schema.arrayOf(
@ -71,11 +81,15 @@ export const registerResolveImportErrorsRoute = (
},
},
catchAndReturnBoomErrors(async (context, req, res) => {
const { createNewCopies } = req.query;
const { createNewCopies, compatibilityMode } = req.query;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient
.incrementSavedObjectsResolveImportErrors({ request: req, createNewCopies })
.incrementSavedObjectsResolveImportErrors({
request: req,
createNewCopies,
compatibilityMode,
})
.catch(() => {});
const file = req.body.file as FileStream;
@ -111,6 +125,7 @@ export const registerResolveImportErrorsRoute = (
readStream,
retries: req.body.retries,
createNewCopies,
compatibilityMode,
});
return res.ok({ body: result });

View file

@ -64,6 +64,11 @@ export interface SavedObjectsImportOptions {
createNewCopies: boolean;
/** Refresh setting, defaults to `wait_for` */
refresh?: boolean | 'wait_for';
/**
* If true, Kibana will apply various adjustments to the data that's being imported to maintain compatibility between
* different Kibana versions (e.g. generate legacy URL aliases for all imported objects that have to change IDs).
*/
compatibilityMode?: boolean;
}
/**
@ -79,6 +84,11 @@ export interface SavedObjectsResolveImportErrorsOptions {
namespace?: string;
/** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */
createNewCopies: boolean;
/**
* If true, Kibana will apply various adjustments to the data that's being retried to import to maintain compatibility between
* different Kibana versions (e.g. generate legacy URL aliases for all imported objects that have to change IDs).
*/
compatibilityMode?: boolean;
}
export type CreatedObject<T> = SavedObject<T> & { destinationId?: string };

View file

@ -18,11 +18,13 @@ export interface BaseIncrementOptions {
export type IncrementSavedObjectsImportOptions = BaseIncrementOptions & {
overwrite: boolean;
createNewCopies: boolean;
compatibilityMode?: boolean;
};
/** @internal */
export type IncrementSavedObjectsResolveImportErrorsOptions = BaseIncrementOptions & {
createNewCopies: boolean;
compatibilityMode?: boolean;
};
/** @internal */

View file

@ -934,6 +934,7 @@ describe('CoreUsageStatsClient', () => {
`${IMPORT_STATS_PREFIX}.namespace.default.kibanaRequest.no`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${IMPORT_STATS_PREFIX}.overwriteEnabled.no`,
`${IMPORT_STATS_PREFIX}.compatibilityModeEnabled.no`,
],
incrementOptions
);
@ -947,11 +948,13 @@ describe('CoreUsageStatsClient', () => {
request,
createNewCopies: true,
overwrite: true,
compatibilityMode: true,
} as IncrementSavedObjectsImportOptions);
await usageStatsClient.incrementSavedObjectsImport({
request,
createNewCopies: false,
overwrite: true,
compatibilityMode: true,
} as IncrementSavedObjectsImportOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(2);
expect(repositoryMock.incrementCounter).toHaveBeenNthCalledWith(
@ -963,7 +966,8 @@ describe('CoreUsageStatsClient', () => {
`${IMPORT_STATS_PREFIX}.namespace.default.total`,
`${IMPORT_STATS_PREFIX}.namespace.default.kibanaRequest.yes`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`,
// excludes 'overwriteEnabled.yes' and 'overwriteEnabled.no' when createNewCopies is true
// excludes 'overwriteEnabled.yes', 'overwriteEnabled.no', 'compatibilityModeEnabled.yes`, and
// `compatibilityModeEnabled.no` when createNewCopies is true
],
incrementOptions
);
@ -977,6 +981,7 @@ describe('CoreUsageStatsClient', () => {
`${IMPORT_STATS_PREFIX}.namespace.default.kibanaRequest.yes`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${IMPORT_STATS_PREFIX}.overwriteEnabled.yes`,
`${IMPORT_STATS_PREFIX}.compatibilityModeEnabled.yes`,
],
incrementOptions
);
@ -999,6 +1004,7 @@ describe('CoreUsageStatsClient', () => {
`${IMPORT_STATS_PREFIX}.namespace.custom.kibanaRequest.no`,
`${IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${IMPORT_STATS_PREFIX}.overwriteEnabled.no`,
`${IMPORT_STATS_PREFIX}.compatibilityModeEnabled.no`,
],
incrementOptions
);
@ -1035,6 +1041,7 @@ describe('CoreUsageStatsClient', () => {
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.default.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.default.kibanaRequest.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.compatibilityModeEnabled.no`,
],
incrementOptions
);
@ -1047,9 +1054,16 @@ describe('CoreUsageStatsClient', () => {
await usageStatsClient.incrementSavedObjectsResolveImportErrors({
request,
createNewCopies: true,
compatibilityMode: true,
} as IncrementSavedObjectsResolveImportErrorsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
await usageStatsClient.incrementSavedObjectsResolveImportErrors({
request,
createNewCopies: false,
compatibilityMode: true,
} as IncrementSavedObjectsResolveImportErrorsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(2);
expect(repositoryMock.incrementCounter).toHaveBeenNthCalledWith(
1,
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
@ -1057,6 +1071,20 @@ describe('CoreUsageStatsClient', () => {
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.default.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.default.kibanaRequest.yes`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.yes`,
// excludes 'compatibilityModeEnabled.yes` and `compatibilityModeEnabled.no` when createNewCopies is true
],
incrementOptions
);
expect(repositoryMock.incrementCounter).toHaveBeenNthCalledWith(
2,
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${RESOLVE_IMPORT_STATS_PREFIX}.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.default.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.default.kibanaRequest.yes`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.compatibilityModeEnabled.yes`,
],
incrementOptions
);
@ -1078,6 +1106,7 @@ describe('CoreUsageStatsClient', () => {
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.custom.total`,
`${RESOLVE_IMPORT_STATS_PREFIX}.namespace.custom.kibanaRequest.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${RESOLVE_IMPORT_STATS_PREFIX}.compatibilityModeEnabled.no`,
],
incrementOptions
);

View file

@ -145,10 +145,11 @@ export class CoreUsageStatsClient implements ICoreUsageStatsClient {
}
public async incrementSavedObjectsImport(options: IncrementSavedObjectsImportOptions) {
const { createNewCopies, overwrite } = options;
const { createNewCopies, overwrite, compatibilityMode } = options;
const counterFieldNames = [
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
...(!createNewCopies ? [`overwriteEnabled.${overwrite ? 'yes' : 'no'}`] : []), // the overwrite option is ignored when createNewCopies is true
...(!createNewCopies ? [`compatibilityModeEnabled.${compatibilityMode ? 'yes' : 'no'}`] : []), // the compatibilityMode option is ignored when createNewCopies is true
];
await this.updateUsageStats(counterFieldNames, IMPORT_STATS_PREFIX, options);
}
@ -156,8 +157,11 @@ export class CoreUsageStatsClient implements ICoreUsageStatsClient {
public async incrementSavedObjectsResolveImportErrors(
options: IncrementSavedObjectsResolveImportErrorsOptions
) {
const { createNewCopies } = options;
const counterFieldNames = [`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`];
const { createNewCopies, compatibilityMode } = options;
const counterFieldNames = [
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
...(!createNewCopies ? [`compatibilityModeEnabled.${compatibilityMode ? 'yes' : 'no'}`] : []), // the compatibilityMode option is ignored when createNewCopies is true
];
await this.updateUsageStats(counterFieldNames, RESOLVE_IMPORT_STATS_PREFIX, options);
}

View file

@ -16,7 +16,10 @@ import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { SavedObjectConfig } from '@kbn/core-saved-objects-base-server-internal';
import {
LEGACY_URL_ALIAS_TYPE,
SavedObjectConfig,
} from '@kbn/core-saved-objects-base-server-internal';
import { SavedObjectsImporter } from '@kbn/core-saved-objects-import-export-server-internal';
import {
registerImportRoute,
@ -112,6 +115,7 @@ describe(`POST ${URL}`, () => {
request: expect.anything(),
createNewCopies: false,
overwrite: false,
compatibilityMode: false,
});
});
@ -563,4 +567,428 @@ describe(`POST ${URL}`, () => {
);
});
});
describe('compatibilityMode enabled', () => {
it('imports objects and creates legacy URL aliases for objects that changed IDs', async () => {
const mockUuid = jest.requireMock('uuid');
mockUuid.v4 = jest
.fn()
.mockReturnValueOnce('foo') // a uuidv4() is generated for the request.id
.mockReturnValueOnce('foo') // another uuidv4() is used for the request.uuid
.mockReturnValueOnce('new-id-1')
.mockReturnValueOnce('new-id-2');
savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] });
// Prepare mock unresolvable conflicts for obj2 and obj3.
savedObjectsClient.checkConflicts.mockResolvedValue({
errors: [
{
type: 'visualization',
id: 'my-vis',
error: {
error: 'some-error',
message: 'some-error-message',
statusCode: 409,
metadata: { isNotOverwritable: true },
},
},
{
type: 'dashboard',
id: 'my-dashboard',
error: {
error: 'some-error',
message: 'some-error-message',
statusCode: 409,
metadata: { isNotOverwritable: true },
},
},
],
});
// Prepare mock results for the imported objects:
// * obj1 - doesn't have conflicts, id won't change, and legacy URL alias won't be created.
// * obj2 and obj3 - have unresolvable conflicts with objects in other spaces, ids will change, and legacy URL
// aliases will be created.
const obj1 = {
type: 'visualization',
id: 'my-stable-vis',
attributes: { title: 'Look at my stable visualization' },
references: [],
};
const obj2 = {
type: 'visualization',
id: 'new-id-1',
originId: 'my-vis',
attributes: { title: 'Look at my visualization' },
references: [],
};
const obj3 = {
type: 'dashboard',
id: 'new-id-2',
originId: 'my-dashboard',
attributes: { title: 'Look at my dashboard' },
references: [],
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [obj1, obj2, obj3] });
// Prepare mock results for the created legacy URL aliases (for obj1 and obj2).
const [legacyUrlAliasObj2, legacyUrlAliasObj3] = [obj2, obj3].map(
({ type, originId, id }) => ({
id: `default:${type}:${originId}`,
type: LEGACY_URL_ALIAS_TYPE,
references: [],
attributes: {
sourceId: originId,
targetNamespace: 'default',
targetType: type,
targetId: id,
purpose: 'savedObjectImport',
},
})
);
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [legacyUrlAliasObj2, legacyUrlAliasObj3],
});
const result = await supertest(httpSetup.server.listener)
.post(`${URL}?compatibilityMode=true`)
.set('content-Type', 'multipart/form-data; boundary=EXAMPLE')
.send(
[
'--EXAMPLE',
'Content-Disposition: form-data; name="file"; filename="export.ndjson"',
'Content-Type: application/ndjson',
'',
'{"type":"visualization","id":"my-stable-vis","attributes":{"title":"Look at my stable visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}',
'{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}',
'{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}',
'--EXAMPLE--',
].join('\r\n')
)
.expect(200);
expect(result.body).toEqual({
success: true,
successCount: 3,
successResults: [
{
type: obj1.type,
id: 'my-stable-vis',
meta: { title: obj1.attributes.title, icon: 'visualization-icon' },
},
{
type: obj2.type,
id: 'my-vis',
meta: { title: obj2.attributes.title, icon: 'visualization-icon' },
destinationId: obj2.id,
},
{
type: obj3.type,
id: 'my-dashboard',
meta: { title: obj3.attributes.title, icon: 'dashboard-icon' },
destinationId: obj3.id,
},
],
warnings: [],
});
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(2);
expect(savedObjectsClient.bulkCreate).toHaveBeenNthCalledWith(
1,
[
expect.objectContaining({
type: 'visualization',
id: 'my-stable-vis',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }],
}),
expect.objectContaining({
type: 'visualization',
id: 'new-id-1',
originId: 'my-vis',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }],
}),
expect.objectContaining({
type: 'dashboard',
id: 'new-id-2',
originId: 'my-dashboard',
references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }],
}),
],
expect.any(Object) // options
);
expect(savedObjectsClient.bulkCreate).toHaveBeenNthCalledWith(
2,
[expect.objectContaining(legacyUrlAliasObj2), expect.objectContaining(legacyUrlAliasObj3)],
expect.any(Object) // options
);
});
it('imports objects and creates legacy URL aliases only if object is created', async () => {
const mockUuid = jest.requireMock('uuid');
mockUuid.v4 = jest
.fn()
.mockReturnValueOnce('foo') // a uuidv4() is generated for the request.id
.mockReturnValueOnce('foo') // another uuidv4() is used for the request.uuid
.mockReturnValueOnce('new-id-1')
.mockReturnValueOnce('new-id-2');
savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] });
// Prepare mock unresolvable conflicts for obj1 and obj2.
savedObjectsClient.checkConflicts.mockResolvedValue({
errors: [
{
type: 'visualization',
id: 'my-vis',
error: {
error: 'some-error',
message: 'some-error-message',
statusCode: 409,
metadata: { isNotOverwritable: true },
},
},
{
type: 'dashboard',
id: 'my-dashboard',
error: {
error: 'some-error',
message: 'some-error-message',
statusCode: 409,
metadata: { isNotOverwritable: true },
},
},
],
});
// Prepare mock results for the imported objects:
// * obj1 - has unresolvable conflict with the object in other spaces, id will change, and legacy URL alias will
// be created.
// * obj2 - has unresolvable conflict with the object in other spaces, id will change, but bulk create will fail
// to create the object and hence the legacy URL alias won't be created.
const obj1 = {
type: 'visualization',
id: 'new-id-1',
originId: 'my-vis',
attributes: { title: 'Look at my visualization' },
references: [],
};
const obj2 = {
type: 'dashboard',
id: 'new-id-2',
originId: 'my-dashboard',
attributes: { title: 'Look at my dashboard' },
references: [],
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
obj1,
{
type: obj2.type,
id: obj2.id,
attributes: {},
references: [],
error: { error: 'some-error', message: 'Why not?', statusCode: 503 },
},
],
});
// Prepare mock results for the created legacy URL alias (for obj1 only).
const legacyUrlAliasObj1 = {
id: `default:${obj1.type}:${obj1.originId}`,
type: LEGACY_URL_ALIAS_TYPE,
references: [],
attributes: {
sourceId: obj1.originId,
targetNamespace: 'default',
targetType: obj1.type,
targetId: obj1.id,
purpose: 'savedObjectImport',
},
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [legacyUrlAliasObj1] });
const result = await supertest(httpSetup.server.listener)
.post(`${URL}?compatibilityMode=true`)
.set('content-Type', 'multipart/form-data; boundary=EXAMPLE')
.send(
[
'--EXAMPLE',
'Content-Disposition: form-data; name="file"; filename="export.ndjson"',
'Content-Type: application/ndjson',
'',
'{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}',
'{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}',
'--EXAMPLE--',
].join('\r\n')
)
.expect(200);
expect(result.body).toEqual({
success: false,
successCount: 1,
successResults: [
{
type: obj1.type,
id: obj1.originId,
meta: { title: obj1.attributes.title, icon: 'visualization-icon' },
destinationId: obj1.id,
},
],
errors: [
{
id: obj2.originId,
type: obj2.type,
meta: { title: obj2.attributes.title, icon: 'dashboard-icon' },
error: {
error: 'some-error',
message: 'Why not?',
statusCode: 503,
type: 'unknown',
},
},
],
warnings: [],
});
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(2);
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith(
[
expect.objectContaining({
type: 'visualization',
id: 'new-id-1',
originId: 'my-vis',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }],
}),
expect.objectContaining({
type: 'dashboard',
id: 'new-id-2',
originId: 'my-dashboard',
references: [{ name: 'ref_0', type: 'visualization', id: 'new-id-1' }],
}),
],
expect.any(Object) // options
);
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith(
[expect.objectContaining(legacyUrlAliasObj1)],
expect.any(Object) // options
);
});
it('returns error if fails to create a legacy URL alias', async () => {
const mockUuid = jest.requireMock('uuid');
mockUuid.v4 = jest
.fn()
.mockReturnValueOnce('foo') // a uuidv4() is generated for the request.id
.mockReturnValueOnce('foo') // another uuidv4() is used for the request.uuid
.mockReturnValueOnce('new-id-1');
savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] });
// Prepare mock unresolvable conflict for obj1.
savedObjectsClient.checkConflicts.mockResolvedValue({
errors: [
{
type: 'visualization',
id: 'my-vis',
error: {
error: 'some-error',
message: 'some-error-message',
statusCode: 409,
metadata: { isNotOverwritable: true },
},
},
],
});
// Prepare mock results for the imported object that has unresolvable conflict with the object in other spaces,
// id will change, but legacy URL alias won't be created because of unexpected error.
const obj1 = {
type: 'visualization',
id: 'new-id-1',
originId: 'my-vis',
attributes: { title: 'Look at my visualization' },
references: [],
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [obj1] });
// Prepare mock results for the created legacy URL alias (for obj1 only).
const legacyUrlAliasObj1 = {
id: `default:${obj1.type}:${obj1.originId}`,
type: LEGACY_URL_ALIAS_TYPE,
references: [],
attributes: {
sourceId: obj1.originId,
targetNamespace: 'default',
targetType: obj1.type,
targetId: obj1.id,
purpose: 'savedObjectImport',
},
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
type: legacyUrlAliasObj1.type,
id: legacyUrlAliasObj1.id,
attributes: {},
references: [],
error: { error: 'some-error', message: 'Why not?', statusCode: 503 },
},
],
});
const result = await supertest(httpSetup.server.listener)
.post(`${URL}?compatibilityMode=true`)
.set('content-Type', 'multipart/form-data; boundary=EXAMPLE')
.send(
[
'--EXAMPLE',
'Content-Disposition: form-data; name="file"; filename="export.ndjson"',
'Content-Type: application/ndjson',
'',
'{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}',
'--EXAMPLE--',
].join('\r\n')
)
.expect(200);
expect(result.body).toEqual({
success: false,
successCount: 1,
successResults: [
{
type: obj1.type,
id: obj1.originId,
meta: { title: obj1.attributes.title, icon: 'visualization-icon' },
destinationId: obj1.id,
},
],
errors: [
{
id: legacyUrlAliasObj1.id,
type: legacyUrlAliasObj1.type,
error: {
error: 'some-error',
message: 'Why not?',
statusCode: 503,
type: 'unknown',
},
meta: { title: 'Legacy URL alias (my-vis -> new-id-1)', icon: 'legacy-url-alias-icon' },
},
],
warnings: [],
});
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(2);
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith(
[
expect.objectContaining({
type: 'visualization',
id: 'new-id-1',
originId: 'my-vis',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'my-pattern' }],
}),
],
expect.any(Object) // options
);
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith(
[expect.objectContaining(legacyUrlAliasObj1)],
expect.any(Object) // options
);
});
});
});

View file

@ -16,7 +16,10 @@ import {
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer, createExportableType } from '@kbn/core-test-helpers-test-utils';
import { SavedObjectConfig } from '@kbn/core-saved-objects-base-server-internal';
import {
LEGACY_URL_ALIAS_TYPE,
SavedObjectConfig,
} from '@kbn/core-saved-objects-base-server-internal';
import { SavedObjectsImporter } from '@kbn/core-saved-objects-import-export-server-internal';
import {
registerResolveImportErrorsRoute,
@ -122,6 +125,7 @@ describe(`POST ${URL}`, () => {
expect(coreUsageStatsClient.incrementSavedObjectsResolveImportErrors).toHaveBeenCalledWith({
request: expect.anything(),
createNewCopies: false,
compatibilityMode: false,
});
});
@ -413,4 +417,106 @@ describe(`POST ${URL}`, () => {
);
});
});
describe('compatibilityMode enabled', () => {
it('imports objects and creates legacy URL aliases', async () => {
const mockUuid = jest.requireMock('uuid');
mockUuid.v4 = jest.fn().mockReturnValue('new-id-1');
savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] });
const obj1 = {
type: 'visualization',
id: 'my-vis',
attributes: { title: 'Look at my visualization' },
references: [],
};
const obj2 = {
type: 'dashboard',
id: 'new-id-2',
originId: 'my-dashboard',
attributes: { title: 'Look at my dashboard' },
references: [],
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [obj1, obj2] });
// Prepare mock results for the created legacy URL alias for obj2.
const legacyUrlAliasObj2 = {
id: `default:${obj2.type}:${obj2.originId}`,
type: LEGACY_URL_ALIAS_TYPE,
references: [],
attributes: {
sourceId: obj2.originId,
targetNamespace: 'default',
targetType: obj2.type,
targetId: obj2.id,
purpose: 'savedObjectImport',
},
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [legacyUrlAliasObj2],
});
const result = await supertest(httpSetup.server.listener)
.post(`${URL}?compatibilityMode=true`)
.set('content-Type', 'multipart/form-data; boundary=EXAMPLE')
.send(
[
'--EXAMPLE',
'Content-Disposition: form-data; name="file"; filename="export.ndjson"',
'Content-Type: application/ndjson',
'',
'{"type":"visualization","id":"my-vis","attributes":{"title":"Look at my visualization"},"references":[{"name":"ref_0","type":"index-pattern","id":"my-pattern"}]}',
'{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"},"references":[{"name":"ref_0","type":"visualization","id":"my-vis"}]}',
'--EXAMPLE',
'Content-Disposition: form-data; name="retries"',
'',
'[{"type":"visualization","id":"my-vis","replaceReferences":[{"type":"index-pattern","from":"my-pattern","to":"existing"}]},{"type":"dashboard","id":"my-dashboard","destinationId":"new-id-2"}]',
'--EXAMPLE--',
].join('\r\n')
)
.expect(200);
expect(result.body).toEqual({
success: true,
successCount: 2,
successResults: [
{
type: obj1.type,
id: 'my-vis',
meta: { title: obj1.attributes.title, icon: 'visualization-icon' },
},
{
type: obj2.type,
id: 'my-dashboard',
meta: { title: obj2.attributes.title, icon: 'dashboard-icon' },
destinationId: obj2.id,
},
],
warnings: [],
});
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(2); // successResults objects were created because no resolvable errors are present
expect(savedObjectsClient.bulkCreate).toHaveBeenNthCalledWith(
1,
[
expect.objectContaining({
type: 'visualization',
id: 'my-vis',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'existing' }],
}),
expect.objectContaining({
type: 'dashboard',
id: 'new-id-2',
originId: 'my-dashboard',
references: [{ name: 'ref_0', type: 'visualization', id: 'my-vis' }],
}),
],
expect.any(Object) // options
);
expect(savedObjectsClient.bulkCreate).toHaveBeenNthCalledWith(
2,
[expect.objectContaining(legacyUrlAliasObj2)],
expect.any(Object) // options
);
});
});
});

View file

@ -288,7 +288,7 @@ describe('copySavedObjectsToSpaces', () => {
});
it(`doesn't stop copy if some spaces fail`, async () => {
const { savedObjects } = setup({
const { savedObjects, savedObjectsImporter } = setup({
objects: mockExportResults,
});
@ -335,6 +335,34 @@ describe('copySavedObjectsToSpaces', () => {
},
}
`);
expect(savedObjectsImporter.import).toHaveBeenCalledTimes(3);
expect(savedObjectsImporter.import).toHaveBeenCalledWith(
expect.objectContaining({ compatibilityMode: undefined })
);
});
it(`properly forwards compatibility mode setting`, async () => {
const { savedObjects, savedObjectsImporter } = setup({
objects: mockExportResults,
});
const request = httpServerMock.createKibanaRequest();
const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory(savedObjects, request);
await copySavedObjectsToSpaces('sourceSpace', ['marketing'], {
includeReferences: true,
overwrite: true,
objects: [{ type: 'dashboard', id: 'my-dashboard' }],
createNewCopies: false,
compatibilityMode: true,
});
expect(savedObjectsImporter.import).toHaveBeenCalledTimes(1);
expect(savedObjectsImporter.import).toHaveBeenCalledWith(
expect.objectContaining({ compatibilityMode: true })
);
});
it(`handles stream read errors`, async () => {

View file

@ -52,6 +52,7 @@ export function copySavedObjectsToSpacesFactory(
overwrite: options.overwrite,
readStream: objectsStream,
createNewCopies: options.createNewCopies,
compatibilityMode: options.compatibilityMode,
});
return {

View file

@ -185,6 +185,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => {
const importOptions = {
createNewCopies: false,
compatibilityMode: undefined,
readStream: expect.any(Readable),
};
expect(savedObjectsImporter.resolveImportErrors).toHaveBeenNthCalledWith(1, {
@ -199,6 +200,40 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => {
});
});
it('properly forwards compatibility mode setting', async () => {
const { savedObjects, savedObjectsImporter } = setup({
objects: mockExportResults,
});
const request = httpServerMock.createKibanaRequest();
const resolveCopySavedObjectsToSpacesConflicts =
resolveCopySavedObjectsToSpacesConflictsFactory(savedObjects, request);
const namespace = 'sourceSpace';
const objects = [{ type: 'dashboard', id: 'my-dashboard' }];
const retries = {
destination1: [{ type: 'visualization', id: 'my-visualization', overwrite: true }],
destination2: [{ type: 'visualization', id: 'my-visualization', overwrite: false }],
};
await resolveCopySavedObjectsToSpacesConflicts(namespace, {
includeReferences: true,
objects,
retries,
createNewCopies: false,
compatibilityMode: true,
});
expect(savedObjectsImporter.resolveImportErrors).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ compatibilityMode: true })
);
expect(savedObjectsImporter.resolveImportErrors).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ compatibilityMode: true })
);
});
it(`doesn't stop resolution if some spaces fail`, async () => {
const { savedObjects } = setup({
objects: mockExportResults,

View file

@ -50,7 +50,8 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory(
spaceId: string,
objectsStream: Readable,
retries: SavedObjectsImportRetry[],
createNewCopies: boolean
createNewCopies: boolean,
compatibilityMode?: boolean
) => {
try {
const importResponse = await savedObjectsImporter.resolveImportErrors({
@ -58,6 +59,7 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory(
readStream: objectsStream,
retries,
createNewCopies,
compatibilityMode,
});
return {
@ -99,7 +101,8 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory(
spaceId,
createReadableStreamFromArray(filteredObjects),
retries,
options.createNewCopies
options.createNewCopies,
options.compatibilityMode
);
}

View file

@ -19,6 +19,7 @@ export interface CopyOptions {
overwrite: boolean;
includeReferences: boolean;
createNewCopies: boolean;
compatibilityMode?: boolean;
}
export interface ResolveConflictsOptions {
@ -28,6 +29,7 @@ export interface ResolveConflictsOptions {
[spaceId: string]: Array<Omit<SavedObjectsImportRetry, 'replaceReferences'>>;
};
createNewCopies: boolean;
compatibilityMode?: boolean;
}
export interface CopyResponse {

View file

@ -130,7 +130,14 @@ describe('copy to space', () => {
it(`records usageStats data`, async () => {
const createNewCopies = Symbol();
const overwrite = Symbol();
const payload = { spaces: ['a-space'], objects: [], createNewCopies, overwrite };
const compatibilityMode = Symbol();
const payload = {
spaces: ['a-space'],
objects: [],
createNewCopies,
overwrite,
compatibilityMode,
};
const { copyToSpace, usageStatsClient } = await setup();
@ -145,6 +152,7 @@ describe('copy to space', () => {
headers: request.headers,
createNewCopies,
overwrite,
compatibilityMode,
});
});
@ -211,6 +219,23 @@ describe('copy to space', () => {
).toThrowErrorMatchingInlineSnapshot(`"cannot use [overwrite] with [createNewCopies]"`);
});
it(`does not allow "compatibilityMode" to be used with "createNewCopies"`, async () => {
const payload = {
spaces: ['a-space'],
objects: [{ type: 'foo', id: 'bar' }],
compatibilityMode: true,
createNewCopies: true,
};
const { copyToSpace } = await setup();
expect(() =>
(copyToSpace.routeValidation.body as ObjectType).validate(payload)
).toThrowErrorMatchingInlineSnapshot(
`"cannot use [compatibilityMode] with [createNewCopies]"`
);
});
it(`requires objects to be unique`, async () => {
const payload = {
spaces: ['a-space'],
@ -262,6 +287,46 @@ describe('copy to space', () => {
namespace: 'b-space',
});
});
it('properly forwards compatibility mode setting', async () => {
const payload = {
spaces: ['a-space', 'b-space'],
objects: [{ type: 'visualization', id: 'bar' }],
compatibilityMode: true,
};
const { copyToSpace, savedObjectsImporter } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
const response = await copyToSpace.routeHandler(
mockRouteContext,
request,
kibanaResponseFactory
);
const { status } = response;
expect(status).toEqual(200);
expect(savedObjectsImporter.import).toHaveBeenCalledTimes(2);
expect(savedObjectsImporter.import).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
namespace: 'a-space',
compatibilityMode: true,
})
);
expect(savedObjectsImporter.import).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
namespace: 'b-space',
compatibilityMode: true,
})
);
});
});
describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => {
@ -286,7 +351,8 @@ describe('copy to space', () => {
it(`records usageStats data`, async () => {
const createNewCopies = Symbol();
const payload = { retries: {}, objects: [], createNewCopies };
const compatibilityMode = Symbol();
const payload = { retries: {}, objects: [], createNewCopies, compatibilityMode };
const { resolveConflicts, usageStatsClient } = await setup();
@ -300,6 +366,7 @@ describe('copy to space', () => {
expect(usageStatsClient.incrementResolveCopySavedObjectsErrors).toHaveBeenCalledWith({
headers: request.headers,
createNewCopies,
compatibilityMode,
});
});
@ -370,6 +437,32 @@ describe('copy to space', () => {
);
});
it(`does not allow "compatibilityMode" to be used with "createNewCopies"`, async () => {
const payload = {
objects: [{ type: 'visualization', id: 'bar' }],
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
],
},
compatibilityMode: true,
createNewCopies: true,
};
const { resolveConflicts, savedObjectsImporter } = await setup();
expect(() =>
(resolveConflicts.routeValidation.body as ObjectType).validate(payload)
).toThrowErrorMatchingInlineSnapshot(
`"cannot use [createNewCopies] with [compatibilityMode]"`
);
expect(savedObjectsImporter.resolveImportErrors).not.toHaveBeenCalled();
});
it('resolves conflicts for multiple spaces', async () => {
const payload = {
objects: [{ type: 'visualization', id: 'bar' }],
@ -418,5 +511,60 @@ describe('copy to space', () => {
expect(resolveImportErrorsSecondCallOptions).toMatchObject({ namespace: 'b-space' });
});
it('properly forwards compatibility mode setting', async () => {
const payload = {
compatibilityMode: true,
objects: [{ type: 'visualization', id: 'bar' }],
retries: {
['a-space']: [
{
type: 'visualization',
id: 'bar',
overwrite: true,
},
],
['b-space']: [
{
type: 'globalType',
id: 'bar',
overwrite: true,
},
],
},
};
const { resolveConflicts, savedObjectsImporter } = await setup();
const request = httpServerMock.createKibanaRequest({
body: payload,
method: 'post',
});
const response = await resolveConflicts.routeHandler(
mockRouteContext,
request,
kibanaResponseFactory
);
const { status } = response;
expect(status).toEqual(200);
expect(savedObjectsImporter.resolveImportErrors).toHaveBeenCalledTimes(2);
expect(savedObjectsImporter.resolveImportErrors).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
namespace: 'a-space',
compatibilityMode: true,
})
);
expect(savedObjectsImporter.resolveImportErrors).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
namespace: 'b-space',
compatibilityMode: true,
})
);
});
});
});

View file

@ -68,12 +68,17 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
includeReferences: schema.boolean({ defaultValue: false }),
overwrite: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: true }),
compatibilityMode: schema.boolean({ defaultValue: false }),
},
{
validate: (object) => {
if (object.overwrite && object.createNewCopies) {
return 'cannot use [overwrite] with [createNewCopies]';
}
if (object.compatibilityMode && object.createNewCopies) {
return 'cannot use [compatibilityMode] with [createNewCopies]';
}
},
}
),
@ -87,11 +92,17 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
includeReferences,
overwrite,
createNewCopies,
compatibilityMode,
} = request.body;
const { headers } = request;
usageStatsClientPromise.then((usageStatsClient) =>
usageStatsClient.incrementCopySavedObjects({ headers, createNewCopies, overwrite })
usageStatsClient.incrementCopySavedObjects({
headers,
createNewCopies,
overwrite,
compatibilityMode,
})
);
const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory(
@ -104,6 +115,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
includeReferences,
overwrite,
createNewCopies,
compatibilityMode,
});
return response.ok({ body: copyResponse });
})
@ -116,51 +128,66 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
tags: ['access:copySavedObjectsToSpaces'],
},
validate: {
body: schema.object({
retries: schema.recordOf(
schema.string({
validate: (spaceId) => {
if (!SPACE_ID_REGEX.test(spaceId)) {
return `Invalid space id: ${spaceId}`;
}
},
}),
schema.arrayOf(
body: schema.object(
{
retries: schema.recordOf(
schema.string({
validate: (spaceId) => {
if (!SPACE_ID_REGEX.test(spaceId)) {
return `Invalid space id: ${spaceId}`;
}
},
}),
schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
overwrite: schema.boolean({ defaultValue: false }),
destinationId: schema.maybe(schema.string()),
createNewCopy: schema.maybe(schema.boolean()),
ignoreMissingReferences: schema.maybe(schema.boolean()),
})
)
),
objects: schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
overwrite: schema.boolean({ defaultValue: false }),
destinationId: schema.maybe(schema.string()),
createNewCopy: schema.maybe(schema.boolean()),
ignoreMissingReferences: schema.maybe(schema.boolean()),
})
)
),
objects: schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
}),
{
validate: (objects) => {
if (!areObjectsUnique(objects)) {
return 'duplicate objects are not allowed';
}
},
}
),
includeReferences: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: true }),
}),
}),
{
validate: (objects) => {
if (!areObjectsUnique(objects)) {
return 'duplicate objects are not allowed';
}
},
}
),
includeReferences: schema.boolean({ defaultValue: false }),
createNewCopies: schema.boolean({ defaultValue: true }),
compatibilityMode: schema.boolean({ defaultValue: false }),
},
{
validate: (object) => {
if (object.createNewCopies && object.compatibilityMode) {
return 'cannot use [createNewCopies] with [compatibilityMode]';
}
},
}
),
},
},
createLicensedRouteHandler(async (context, request, response) => {
const [startServices] = await getStartServices();
const { objects, includeReferences, retries, createNewCopies } = request.body;
const { objects, includeReferences, retries, createNewCopies, compatibilityMode } =
request.body;
const { headers } = request;
usageStatsClientPromise.then((usageStatsClient) =>
usageStatsClient.incrementResolveCopySavedObjectsErrors({ headers, createNewCopies })
usageStatsClient.incrementResolveCopySavedObjectsErrors({
headers,
createNewCopies,
compatibilityMode,
})
);
const resolveCopySavedObjectsToSpacesConflicts =
@ -173,6 +200,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) {
includeReferences,
retries,
createNewCopies,
compatibilityMode,
}
);
return response.ok({ body: resolveConflictsResponse });

View file

@ -32,11 +32,15 @@ const MOCK_USAGE_STATS: UsageStats = {
'apiCalls.copySavedObjects.createNewCopiesEnabled.no': 3,
'apiCalls.copySavedObjects.overwriteEnabled.yes': 1,
'apiCalls.copySavedObjects.overwriteEnabled.no': 4,
'apiCalls.copySavedObjects.compatibilityModeEnabled.yes': 2,
'apiCalls.copySavedObjects.compatibilityModeEnabled.no': 4,
'apiCalls.resolveCopySavedObjectsErrors.total': 13,
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes': 13,
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no': 0,
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes': 6,
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no': 7,
'apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.yes': 0,
'apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.no': 5,
'apiCalls.disableLegacyUrlAliases.total': 17,
};

View file

@ -382,6 +382,20 @@ export function getSpacesUsageCollector(
'The number of times the "Copy Saved Objects" API has been called with "overwrite" set to false.',
},
},
'apiCalls.copySavedObjects.compatibilityModeEnabled.yes': {
type: 'long',
_meta: {
description:
'The number of times the "Copy Saved Objects" API has been called with "compatibilityMode" set to true.',
},
},
'apiCalls.copySavedObjects.compatibilityModeEnabled.no': {
type: 'long',
_meta: {
description:
'The number of times the "Copy Saved Objects" API has been called with "compatibilityMode" set to false.',
},
},
'apiCalls.resolveCopySavedObjectsErrors.total': {
type: 'long',
_meta: {
@ -417,6 +431,20 @@ export function getSpacesUsageCollector(
'The number of times the "Resolve Copy Saved Objects Errors" API has been called with "createNewCopies" set to false.',
},
},
'apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.yes': {
type: 'long',
_meta: {
description:
'The number of times the "Resolve Copy Saved Objects Errors" API has been called with "compatibilityMode" set to true.',
},
},
'apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.no': {
type: 'long',
_meta: {
description:
'The number of times the "Resolve Copy Saved Objects Errors" API has been called with "compatibilityMode" set to false.',
},
},
'apiCalls.disableLegacyUrlAliases.total': {
type: 'long',
_meta: {

View file

@ -13,10 +13,14 @@ export interface UsageStats {
'apiCalls.copySavedObjects.createNewCopiesEnabled.no'?: number;
'apiCalls.copySavedObjects.overwriteEnabled.yes'?: number;
'apiCalls.copySavedObjects.overwriteEnabled.no'?: number;
'apiCalls.copySavedObjects.compatibilityModeEnabled.yes'?: number;
'apiCalls.copySavedObjects.compatibilityModeEnabled.no'?: number;
'apiCalls.resolveCopySavedObjectsErrors.total'?: number;
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.yes'?: number;
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no'?: number;
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes'?: number;
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no'?: number;
'apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.yes'?: number;
'apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.no'?: number;
'apiCalls.disableLegacyUrlAliases.total'?: number;
}

View file

@ -46,11 +46,15 @@ describe('UsageStatsClient', () => {
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${COPY_STATS_PREFIX}.overwriteEnabled.yes`,
`${COPY_STATS_PREFIX}.overwriteEnabled.no`,
`${COPY_STATS_PREFIX}.compatibilityModeEnabled.yes`,
`${COPY_STATS_PREFIX}.compatibilityModeEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.compatibilityModeEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.compatibilityModeEnabled.no`,
`${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`,
],
{ initialize: true }
@ -104,6 +108,7 @@ describe('UsageStatsClient', () => {
`${COPY_STATS_PREFIX}.kibanaRequest.no`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${COPY_STATS_PREFIX}.overwriteEnabled.no`,
`${COPY_STATS_PREFIX}.compatibilityModeEnabled.no`,
],
incrementOptions
);
@ -116,11 +121,13 @@ describe('UsageStatsClient', () => {
headers: firstPartyRequestHeaders,
createNewCopies: true,
overwrite: true,
compatibilityMode: true,
} as IncrementCopySavedObjectsOptions);
await usageStatsClient.incrementCopySavedObjects({
headers: firstPartyRequestHeaders,
createNewCopies: false,
overwrite: true,
compatibilityMode: true,
} as IncrementCopySavedObjectsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(2);
expect(repositoryMock.incrementCounter).toHaveBeenNthCalledWith(
@ -131,7 +138,8 @@ describe('UsageStatsClient', () => {
`${COPY_STATS_PREFIX}.total`,
`${COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
// excludes 'overwriteEnabled.yes' and 'overwriteEnabled.no' when createNewCopies is true
// excludes 'overwriteEnabled.yes', 'overwriteEnabled.no', 'compatibilityModeEnabled.yes`, and
// `compatibilityModeEnabled.no` when createNewCopies is true
],
incrementOptions
);
@ -144,6 +152,7 @@ describe('UsageStatsClient', () => {
`${COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${COPY_STATS_PREFIX}.overwriteEnabled.yes`,
`${COPY_STATS_PREFIX}.compatibilityModeEnabled.yes`,
],
incrementOptions
);
@ -177,6 +186,7 @@ describe('UsageStatsClient', () => {
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.compatibilityModeEnabled.no`,
],
incrementOptions
);
@ -188,15 +198,35 @@ describe('UsageStatsClient', () => {
await usageStatsClient.incrementResolveCopySavedObjectsErrors({
headers: firstPartyRequestHeaders,
createNewCopies: true,
compatibilityMode: true,
} as IncrementResolveCopySavedObjectsErrorsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
await usageStatsClient.incrementResolveCopySavedObjectsErrors({
headers: firstPartyRequestHeaders,
createNewCopies: false,
compatibilityMode: true,
} as IncrementResolveCopySavedObjectsErrorsOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(2);
expect(repositoryMock.incrementCounter).toHaveBeenNthCalledWith(
1,
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
[
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
// excludes 'compatibilityModeEnabled.yes` and `compatibilityModeEnabled.no` when createNewCopies is true
],
incrementOptions
);
expect(repositoryMock.incrementCounter).toHaveBeenNthCalledWith(
2,
SPACES_USAGE_STATS_TYPE,
SPACES_USAGE_STATS_ID,
[
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.compatibilityModeEnabled.yes`,
],
incrementOptions
);

View file

@ -15,9 +15,9 @@ interface BaseIncrementOptions {
headers?: Headers;
}
export type IncrementCopySavedObjectsOptions = BaseIncrementOptions &
Pick<CopyOptions, 'createNewCopies' | 'overwrite'>;
Pick<CopyOptions, 'createNewCopies' | 'overwrite' | 'compatibilityMode'>;
export type IncrementResolveCopySavedObjectsErrorsOptions = BaseIncrementOptions &
Pick<ResolveConflictsOptions, 'createNewCopies'>;
Pick<ResolveConflictsOptions, 'createNewCopies' | 'compatibilityMode'>;
export const COPY_STATS_PREFIX = 'apiCalls.copySavedObjects';
export const RESOLVE_COPY_STATS_PREFIX = 'apiCalls.resolveCopySavedObjectsErrors';
@ -30,11 +30,15 @@ const ALL_COUNTER_FIELDS = [
`${COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${COPY_STATS_PREFIX}.overwriteEnabled.yes`,
`${COPY_STATS_PREFIX}.overwriteEnabled.no`,
`${COPY_STATS_PREFIX}.compatibilityModeEnabled.yes`,
`${COPY_STATS_PREFIX}.compatibilityModeEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.total`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
`${RESOLVE_COPY_STATS_PREFIX}.compatibilityModeEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.compatibilityModeEnabled.no`,
`${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`,
];
export class UsageStatsClient {
@ -65,6 +69,7 @@ export class UsageStatsClient {
headers,
createNewCopies,
overwrite,
compatibilityMode,
}: IncrementCopySavedObjectsOptions) {
const isKibanaRequest = getIsKibanaRequest(headers);
const counterFieldNames = [
@ -72,6 +77,7 @@ export class UsageStatsClient {
`kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`,
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
...(!createNewCopies ? [`overwriteEnabled.${overwrite ? 'yes' : 'no'}`] : []), // the overwrite option is ignored when createNewCopies is true
...(!createNewCopies ? [`compatibilityModeEnabled.${compatibilityMode ? 'yes' : 'no'}`] : []), // the compatibilityMode option is ignored when createNewCopies is true
];
await this.updateUsageStats(counterFieldNames, COPY_STATS_PREFIX);
}
@ -79,12 +85,14 @@ export class UsageStatsClient {
public async incrementResolveCopySavedObjectsErrors({
headers,
createNewCopies,
compatibilityMode,
}: IncrementResolveCopySavedObjectsErrorsOptions) {
const isKibanaRequest = getIsKibanaRequest(headers);
const counterFieldNames = [
'total',
`kibanaRequest.${isKibanaRequest ? 'yes' : 'no'}`,
`createNewCopiesEnabled.${createNewCopies ? 'yes' : 'no'}`,
...(!createNewCopies ? [`compatibilityModeEnabled.${compatibilityMode ? 'yes' : 'no'}`] : []), // the compatibilityMode option is ignored when createNewCopies is true
];
await this.updateUsageStats(counterFieldNames, RESOLVE_COPY_STATS_PREFIX);
}

View file

@ -14244,6 +14244,18 @@
"description": "The number of times the \"Copy Saved Objects\" API has been called with \"overwrite\" set to false."
}
},
"apiCalls.copySavedObjects.compatibilityModeEnabled.yes": {
"type": "long",
"_meta": {
"description": "The number of times the \"Copy Saved Objects\" API has been called with \"compatibilityMode\" set to true."
}
},
"apiCalls.copySavedObjects.compatibilityModeEnabled.no": {
"type": "long",
"_meta": {
"description": "The number of times the \"Copy Saved Objects\" API has been called with \"compatibilityMode\" set to false."
}
},
"apiCalls.resolveCopySavedObjectsErrors.total": {
"type": "long",
"_meta": {
@ -14274,6 +14286,18 @@
"description": "The number of times the \"Resolve Copy Saved Objects Errors\" API has been called with \"createNewCopies\" set to false."
}
},
"apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.yes": {
"type": "long",
"_meta": {
"description": "The number of times the \"Resolve Copy Saved Objects Errors\" API has been called with \"compatibilityMode\" set to true."
}
},
"apiCalls.resolveCopySavedObjectsErrors.compatibilityModeEnabled.no": {
"type": "long",
"_meta": {
"description": "The number of times the \"Resolve Copy Saved Objects Errors\" API has been called with \"compatibilityMode\" set to false."
}
},
"apiCalls.disableLegacyUrlAliases.total": {
"type": "long",
"_meta": {