mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Support generating legacy URL aliases for objects that change IDs during import. (#149021)
This commit is contained in:
parent
b15737a7f0
commit
a1fccfd880
42 changed files with 1452 additions and 108 deletions
|
@ -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
|
||||
|
|
|
@ -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]]
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -32,6 +32,7 @@ const createRepositoryMock = () => {
|
|||
removeReferencesTo: jest.fn(),
|
||||
collectMultiNamespaceReferences: jest.fn(),
|
||||
updateObjectsSpaces: jest.fn(),
|
||||
getCurrentNamespace: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -208,4 +208,9 @@ export class SavedObjectsClient implements SavedObjectsClientContract {
|
|||
options
|
||||
);
|
||||
}
|
||||
|
||||
/** {@inheritDoc SavedObjectsClientContract.getCurrentNamespace} */
|
||||
getCurrentNamespace() {
|
||||
return this._repository.getCurrentNamespace();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ const create = () => {
|
|||
removeReferencesTo: jest.fn(),
|
||||
collectMultiNamespaceReferences: jest.fn(),
|
||||
updateObjectsSpaces: jest.fn(),
|
||||
getCurrentNamespace: jest.fn(),
|
||||
};
|
||||
|
||||
mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -52,6 +52,7 @@ export function copySavedObjectsToSpacesFactory(
|
|||
overwrite: options.overwrite,
|
||||
readStream: objectsStream,
|
||||
createNewCopies: options.createNewCopies,
|
||||
compatibilityMode: options.compatibilityMode,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue