Saved object export: apply export hooks to referenced / nested objects (#100769)

* execute export transform for nested references

* fix sort

* fix duplicate references

* add FTR test
This commit is contained in:
Pierre Gayvallet 2021-06-04 14:46:05 +02:00 committed by GitHub
parent d62bb452dd
commit aa8aa7f23d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1423 additions and 786 deletions

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const applyExportTransformsMock = jest.fn();
jest.doMock('./apply_export_transforms', () => ({
applyExportTransforms: applyExportTransformsMock,
}));

View file

@ -0,0 +1,528 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { applyExportTransformsMock } from './collect_exported_objects.test.mocks';
import { savedObjectsClientMock } from '../../mocks';
import { httpServerMock } from '../../http/http_server.mocks';
import { SavedObject, SavedObjectError } from '../../../types';
import type { SavedObjectsExportTransform } from './types';
import { collectExportedObjects } from './collect_exported_objects';
const createObject = (parts: Partial<SavedObject>): SavedObject => ({
id: 'id',
type: 'type',
references: [],
attributes: {},
...parts,
});
const createError = (parts: Partial<SavedObjectError> = {}): SavedObjectError => ({
error: 'error',
message: 'message',
statusCode: 404,
...parts,
});
const toIdTuple = (obj: SavedObject) => ({ type: obj.type, id: obj.id });
describe('collectExportedObjects', () => {
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
let request: ReturnType<typeof httpServerMock.createKibanaRequest>;
beforeEach(() => {
savedObjectsClient = savedObjectsClientMock.create();
request = httpServerMock.createKibanaRequest();
applyExportTransformsMock.mockImplementation(({ objects }) => objects);
savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [] });
});
afterEach(() => {
applyExportTransformsMock.mockReset();
savedObjectsClient.bulkGet.mockReset();
});
describe('when `includeReferences` is `true`', () => {
it('calls `applyExportTransforms` with the correct parameters', async () => {
const obj1 = createObject({
type: 'foo',
id: '1',
});
const obj2 = createObject({
type: 'foo',
id: '2',
});
const fooTransform: SavedObjectsExportTransform = jest.fn();
await collectExportedObjects({
objects: [obj1, obj2],
savedObjectsClient,
request,
exportTransforms: { foo: fooTransform },
includeReferences: true,
});
expect(applyExportTransformsMock).toHaveBeenCalledTimes(1);
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [obj1, obj2],
transforms: { foo: fooTransform },
request,
});
});
it('returns the collected objects', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'bar',
id: '2',
name: 'bar-2',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
});
const dolly3 = createObject({
type: 'dolly',
id: '3',
});
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2],
});
const { objects, missingRefs } = await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(missingRefs).toHaveLength(0);
expect(objects.map(toIdTuple)).toEqual([foo1, dolly3, bar2].map(toIdTuple));
});
it('returns the missing references', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'bar',
id: '2',
name: 'bar-2',
},
{
type: 'missing',
id: '1',
name: 'missing-1',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
references: [
{
type: 'missing',
id: '2',
name: 'missing-2',
},
],
});
const missing1 = createObject({
type: 'missing',
id: '1',
error: createError(),
});
const missing2 = createObject({
type: 'missing',
id: '2',
error: createError(),
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2, missing1],
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [missing2],
});
const { objects, missingRefs } = await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(missingRefs).toEqual([missing1, missing2].map(toIdTuple));
expect(objects.map(toIdTuple)).toEqual([foo1, bar2].map(toIdTuple));
});
it('does not call `client.bulkGet` when no objects have references', async () => {
const obj1 = createObject({
type: 'foo',
id: '1',
});
const obj2 = createObject({
type: 'foo',
id: '2',
});
const { objects, missingRefs } = await collectExportedObjects({
objects: [obj1, obj2],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(missingRefs).toHaveLength(0);
expect(objects.map(toIdTuple)).toEqual([
{
type: 'foo',
id: '1',
},
{
type: 'foo',
id: '2',
},
]);
expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled();
});
it('calls `applyExportTransforms` for each iteration', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'bar',
id: '2',
name: 'bar-2',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2],
});
await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(
[toIdTuple(bar2)],
expect.any(Object)
);
expect(applyExportTransformsMock).toHaveBeenCalledTimes(2);
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [foo1],
transforms: {},
request,
});
expect(applyExportTransformsMock).toHaveBeenCalledWith({
objects: [bar2],
transforms: {},
request,
});
});
it('ignores references that are already included in the export', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'bar',
id: '2',
name: 'bar-2',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
references: [
{
type: 'foo',
id: '1',
name: 'foo-1',
},
{
type: 'dolly',
id: '3',
name: 'dolly-3',
},
],
});
const dolly3 = createObject({
type: 'dolly',
id: '3',
references: [
{
type: 'foo',
id: '1',
name: 'foo-1',
},
],
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2],
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [foo1, dolly3],
});
const { objects } = await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2);
expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith(
1,
[toIdTuple(bar2)],
expect.any(Object)
);
expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith(
2,
[toIdTuple(dolly3)],
expect.any(Object)
);
expect(objects.map(toIdTuple)).toEqual([foo1, bar2, dolly3].map(toIdTuple));
});
it('does not fetch duplicates of references', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'dolly',
id: '3',
name: 'dolly-3',
},
{
type: 'baz',
id: '4',
name: 'baz-4',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
references: [
{
type: 'dolly',
id: '3',
name: 'dolly-3',
},
],
});
const dolly3 = createObject({
type: 'dolly',
id: '3',
});
const baz4 = createObject({
type: 'baz',
id: '4',
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [dolly3, baz4],
});
await collectExportedObjects({
objects: [foo1, bar2],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(
[dolly3, baz4].map(toIdTuple),
expect.any(Object)
);
});
it('fetch references for additional objects returned by the export transform', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'baz',
id: '4',
name: 'baz-4',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
references: [
{
type: 'dolly',
id: '3',
name: 'dolly-3',
},
],
});
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, bar2]);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [],
});
await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(
[
{ type: 'baz', id: '4' },
{ type: 'dolly', id: '3' },
],
expect.any(Object)
);
});
it('fetch references for additional objects returned by the export transform of nested references', async () => {
const foo1 = createObject({
type: 'foo',
id: '1',
references: [
{
type: 'bar',
id: '2',
name: 'bar-2',
},
],
});
const bar2 = createObject({
type: 'bar',
id: '2',
references: [],
});
const dolly3 = createObject({
type: 'dolly',
id: '3',
references: [
{
type: 'baz',
id: '4',
name: 'baz-4',
},
],
});
const baz4 = createObject({
type: 'baz',
id: '4',
});
// first call for foo-1
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects]);
// second call for bar-2
applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]);
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [bar2],
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [baz4],
});
await collectExportedObjects({
objects: [foo1],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: true,
});
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2);
expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith(
1,
[toIdTuple(bar2)],
expect.any(Object)
);
expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith(
2,
[toIdTuple(baz4)],
expect.any(Object)
);
});
});
describe('when `includeReferences` is `false`', () => {
it('does not fetch the object references', async () => {
const obj1 = createObject({
type: 'foo',
id: '1',
references: [
{
id: '2',
type: 'bar',
name: 'bar-2',
},
],
});
const { objects, missingRefs } = await collectExportedObjects({
objects: [obj1],
savedObjectsClient,
request,
exportTransforms: {},
includeReferences: false,
});
expect(missingRefs).toHaveLength(0);
expect(objects.map(toIdTuple)).toEqual([
{
type: 'foo',
id: '1',
},
]);
expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,128 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SavedObject } from '../../../types';
import type { KibanaRequest } from '../../http';
import { SavedObjectsClientContract } from '../types';
import type { SavedObjectsExportTransform } from './types';
import { applyExportTransforms } from './apply_export_transforms';
interface CollectExportedObjectOptions {
savedObjectsClient: SavedObjectsClientContract;
objects: SavedObject[];
/** flag to also include all related saved objects in the export stream. */
includeReferences?: boolean;
/** optional namespace to override the namespace used by the savedObjectsClient. */
namespace?: string;
/** The http request initiating the export. */
request: KibanaRequest;
/** export transform per type */
exportTransforms: Record<string, SavedObjectsExportTransform>;
}
interface CollectExportedObjectResult {
objects: SavedObject[];
missingRefs: CollectedReference[];
}
export const collectExportedObjects = async ({
objects,
includeReferences = true,
namespace,
request,
exportTransforms,
savedObjectsClient,
}: CollectExportedObjectOptions): Promise<CollectExportedObjectResult> => {
const collectedObjects: SavedObject[] = [];
const collectedMissingRefs: CollectedReference[] = [];
const alreadyProcessed: Set<string> = new Set();
let currentObjects = objects;
do {
const transformed = (
await applyExportTransforms({
request,
objects: currentObjects,
transforms: exportTransforms,
})
).filter((object) => !alreadyProcessed.has(objKey(object)));
transformed.forEach((obj) => alreadyProcessed.add(objKey(obj)));
collectedObjects.push(...transformed);
if (includeReferences) {
const references = collectReferences(transformed, alreadyProcessed);
if (references.length) {
const { objects: fetchedObjects, missingRefs } = await fetchReferences({
references,
namespace,
client: savedObjectsClient,
});
collectedMissingRefs.push(...missingRefs);
currentObjects = fetchedObjects;
} else {
currentObjects = [];
}
} else {
currentObjects = [];
}
} while (includeReferences && currentObjects.length);
return {
objects: collectedObjects,
missingRefs: collectedMissingRefs,
};
};
const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`;
type ObjectKey = string;
interface CollectedReference {
id: string;
type: string;
}
const collectReferences = (
objects: SavedObject[],
alreadyProcessed: Set<ObjectKey>
): CollectedReference[] => {
const references: Map<string, CollectedReference> = new Map();
objects.forEach((obj) => {
obj.references?.forEach((ref) => {
const refKey = objKey(ref);
if (!alreadyProcessed.has(refKey)) {
references.set(refKey, { type: ref.type, id: ref.id });
}
});
});
return [...references.values()];
};
interface FetchReferencesResult {
objects: SavedObject[];
missingRefs: CollectedReference[];
}
const fetchReferences = async ({
references,
client,
namespace,
}: {
references: CollectedReference[];
client: SavedObjectsClientContract;
namespace?: string;
}): Promise<FetchReferencesResult> => {
const { saved_objects: savedObjects } = await client.bulkGet(references, { namespace });
return {
objects: savedObjects.filter((obj) => !obj.error),
missingRefs: savedObjects
.filter((obj) => obj.error)
.map((obj) => ({ type: obj.type, id: obj.id })),
};
};

View file

@ -1,606 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { SavedObject } from '../types';
import { savedObjectsClientMock } from '../../mocks';
import { getObjectReferencesToFetch, fetchNestedDependencies } from './fetch_nested_dependencies';
import { SavedObjectsErrorHelpers } from '..';
describe('getObjectReferencesToFetch()', () => {
test('works with no saved objects', () => {
const map = new Map<string, SavedObject>();
const result = getObjectReferencesToFetch(map);
expect(result).toEqual([]);
});
test('excludes already fetched objects', () => {
const map = new Map<string, SavedObject>();
map.set('index-pattern:1', {
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
});
map.set('visualization:2', {
id: '2',
type: 'visualization',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
});
const result = getObjectReferencesToFetch(map);
expect(result).toEqual([]);
});
test('returns objects that are missing', () => {
const map = new Map<string, SavedObject>();
map.set('visualization:2', {
id: '2',
type: 'visualization',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
});
const result = getObjectReferencesToFetch(map);
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"id": "1",
"type": "index-pattern",
},
]
`);
});
test('does not fail on circular dependencies', () => {
const map = new Map<string, SavedObject>();
map.set('index-pattern:1', {
id: '1',
type: 'index-pattern',
attributes: {},
references: [
{
name: 'ref_0',
type: 'visualization',
id: '2',
},
],
});
map.set('visualization:2', {
id: '2',
type: 'visualization',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
});
const result = getObjectReferencesToFetch(map);
expect(result).toEqual([]);
});
});
describe('injectNestedDependencies', () => {
const savedObjectsClient = savedObjectsClientMock.create();
afterEach(() => {
jest.resetAllMocks();
});
test(`doesn't fetch when no dependencies are missing`, async () => {
const savedObjects = [
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
},
];
const result = await fetchNestedDependencies(savedObjects, savedObjectsClient);
expect(result).toMatchInlineSnapshot(`
Object {
"missingRefs": Array [],
"objects": Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
],
}
`);
});
test(`doesn't fetch references that are already fetched`, async () => {
const savedObjects = [
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
},
{
id: '2',
type: 'search',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
},
];
const result = await fetchNestedDependencies(savedObjects, savedObjectsClient);
expect(result).toMatchInlineSnapshot(`
Object {
"missingRefs": Array [],
"objects": Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
],
"type": "search",
},
],
}
`);
});
test('fetches dependencies at least one level deep', async () => {
const savedObjects = [
{
id: '2',
type: 'search',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
},
];
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
},
],
});
const result = await fetchNestedDependencies(savedObjects, savedObjectsClient);
expect(result).toMatchInlineSnapshot(`
Object {
"missingRefs": Array [],
"objects": Array [
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
],
}
`);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Array [
Object {
"id": "1",
"type": "index-pattern",
},
],
Object {
"namespace": undefined,
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
}
`);
});
test('fetches dependencies multiple levels deep', async () => {
const savedObjects = [
{
id: '5',
type: 'dashboard',
attributes: {},
references: [
{
name: 'panel_0',
type: 'visualization',
id: '4',
},
{
name: 'panel_1',
type: 'visualization',
id: '3',
},
],
},
];
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
{
id: '4',
type: 'visualization',
attributes: {},
references: [
{
name: 'ref_0',
type: 'search',
id: '2',
},
],
},
{
id: '3',
type: 'visualization',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
},
],
});
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
{
id: '2',
type: 'search',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
},
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
},
],
});
const result = await fetchNestedDependencies(savedObjects, savedObjectsClient);
expect(result).toMatchInlineSnapshot(`
Object {
"missingRefs": Array [],
"objects": Array [
Object {
"attributes": Object {},
"id": "5",
"references": Array [
Object {
"id": "4",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "3",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
},
Object {
"attributes": Object {},
"id": "4",
"references": Array [
Object {
"id": "2",
"name": "ref_0",
"type": "search",
},
],
"type": "visualization",
},
Object {
"attributes": Object {},
"id": "3",
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
],
"type": "visualization",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
],
}
`);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Array [
Object {
"id": "4",
"type": "visualization",
},
Object {
"id": "3",
"type": "visualization",
},
],
Object {
"namespace": undefined,
},
],
Array [
Array [
Object {
"id": "2",
"type": "search",
},
Object {
"id": "1",
"type": "index-pattern",
},
],
Object {
"namespace": undefined,
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
}
`);
});
test('returns list of missing references', async () => {
const savedObjects = [
{
id: '1',
type: 'search',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
{
name: 'ref_1',
type: 'index-pattern',
id: '2',
},
],
},
];
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
{
id: '1',
type: 'index-pattern',
error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output
.payload,
attributes: {},
references: [],
},
{
id: '2',
type: 'index-pattern',
attributes: {},
references: [],
},
],
});
const result = await fetchNestedDependencies(savedObjects, savedObjectsClient);
expect(result).toMatchInlineSnapshot(`
Object {
"missingRefs": Array [
Object {
"id": "1",
"type": "index-pattern",
},
],
"objects": Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
Object {
"id": "2",
"name": "ref_1",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [],
"type": "index-pattern",
},
],
}
`);
});
test('does not fail on circular dependencies', async () => {
const savedObjects = [
{
id: '2',
type: 'search',
attributes: {},
references: [
{
name: 'ref_0',
type: 'index-pattern',
id: '1',
},
],
},
];
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [
{
name: 'ref_0',
type: 'search',
id: '2',
},
],
},
],
});
const result = await fetchNestedDependencies(savedObjects, savedObjectsClient);
expect(result).toMatchInlineSnapshot(`
Object {
"missingRefs": Array [],
"objects": Array [
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"attributes": Object {},
"id": "1",
"references": Array [
Object {
"id": "2",
"name": "ref_0",
"type": "search",
},
],
"type": "index-pattern",
},
],
}
`);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Array [
Object {
"id": "1",
"type": "index-pattern",
},
],
Object {
"namespace": undefined,
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
}
`);
});
});

View file

@ -1,50 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { SavedObject, SavedObjectsClientContract } from '../types';
export function getObjectReferencesToFetch(savedObjectsMap: Map<string, SavedObject>) {
const objectsToFetch = new Map<string, { type: string; id: string }>();
for (const savedObject of savedObjectsMap.values()) {
for (const ref of savedObject.references || []) {
if (!savedObjectsMap.has(objKey(ref))) {
objectsToFetch.set(objKey(ref), { type: ref.type, id: ref.id });
}
}
}
return [...objectsToFetch.values()];
}
export async function fetchNestedDependencies(
savedObjects: SavedObject[],
savedObjectsClient: SavedObjectsClientContract,
namespace?: string
) {
const savedObjectsMap = new Map<string, SavedObject>();
for (const savedObject of savedObjects) {
savedObjectsMap.set(objKey(savedObject), savedObject);
}
let objectsToFetch = getObjectReferencesToFetch(savedObjectsMap);
while (objectsToFetch.length > 0) {
const bulkGetResponse = await savedObjectsClient.bulkGet(objectsToFetch, { namespace });
// Push to array result
for (const savedObject of bulkGetResponse.saved_objects) {
savedObjectsMap.set(objKey(savedObject), savedObject);
}
objectsToFetch = getObjectReferencesToFetch(savedObjectsMap);
}
const allObjects = [...savedObjectsMap.values()];
return {
objects: allObjects.filter((obj) => !obj.error),
missingRefs: allObjects
.filter((obj) => !!obj.error)
.map((obj) => ({ type: obj.type, id: obj.id })),
};
}
const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`;

View file

@ -12,7 +12,6 @@ import { Logger } from '../../logging';
import { SavedObject, SavedObjectsClientContract } from '../types';
import { SavedObjectsFindResult } from '../service';
import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry';
import { fetchNestedDependencies } from './fetch_nested_dependencies';
import { sortObjects } from './sort_objects';
import {
SavedObjectsExportResultDetails,
@ -22,7 +21,7 @@ import {
SavedObjectsExportTransform,
} from './types';
import { SavedObjectsExportError } from './errors';
import { applyExportTransforms } from './apply_export_transforms';
import { collectExportedObjects } from './collect_exported_objects';
import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils';
/**
@ -118,28 +117,21 @@ export class SavedObjectsExporter {
}: SavedObjectExportBaseOptions
) {
this.#log.debug(`Processing [${savedObjects.length}] saved objects.`);
let exportedObjects: Array<SavedObject<unknown>>;
let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = [];
savedObjects = await applyExportTransforms({
request,
const {
objects: collectedObjects,
missingRefs: missingReferences,
} = await collectExportedObjects({
objects: savedObjects,
transforms: this.#exportTransforms,
sortFunction,
includeReferences: includeReferencesDeep,
namespace,
request,
exportTransforms: this.#exportTransforms,
savedObjectsClient: this.#savedObjectsClient,
});
if (includeReferencesDeep) {
this.#log.debug(`Fetching saved objects references.`);
const fetchResult = await fetchNestedDependencies(
savedObjects,
this.#savedObjectsClient,
namespace
);
exportedObjects = sortObjects(fetchResult.objects);
missingReferences = fetchResult.missingRefs;
} else {
exportedObjects = sortObjects(savedObjects);
}
// sort with the provided sort function then with the default export sorting
const exportedObjects = sortObjects(collectedObjects.sort(sortFunction));
// redact attributes that should not be exported
const redactedObjects = includeNamespaces

View file

@ -0,0 +1,87 @@
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-transform:type_1-obj_1",
"source": {
"test-export-transform": {
"title": "test_1-obj_1",
"enabled": true
},
"type": "test-export-transform",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": [
{
"type": "test-export-transform",
"id": "type_1-obj_2",
"name": "ref-1"
},
{
"type": "test-export-add",
"id": "type_2-obj_1",
"name": "ref-2"
}
]
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-transform:type_1-obj_2",
"source": {
"test-export-transform": {
"title": "test_1-obj_2",
"enabled": true
},
"type": "test-export-transform",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-add:type_2-obj_1",
"source": {
"test-export-add": {
"title": "test_2-obj_1"
},
"type": "test-export-add",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z"
}
}
}
{
"type": "doc",
"value": {
"index": ".kibana",
"type": "doc",
"id": "test-export-add-dep:type_dep-obj_1",
"source": {
"test-export-add-dep": {
"title": "type_dep-obj_1"
},
"type": "test-export-add-dep",
"migrationVersion": {},
"updated_at": "2018-12-21T00:43:07.096Z",
"references": [
{
"type": "test-export-add",
"id": "type_2-obj_1"
}
]
}
}
}

View file

@ -0,0 +1,499 @@
{
"type": "index",
"value": {
"index": ".kibana",
"settings": {
"index": {
"number_of_shards": "1",
"auto_expand_replicas": "0-1",
"number_of_replicas": "0"
}
},
"mappings": {
"dynamic": "strict",
"properties": {
"test-export-transform": {
"properties": {
"title": { "type": "text" },
"enabled": { "type": "boolean" }
}
},
"test-export-add": {
"properties": {
"title": { "type": "text" }
}
},
"test-export-add-dep": {
"properties": {
"title": { "type": "text" }
}
},
"test-export-transform-error": {
"properties": {
"title": { "type": "text" }
}
},
"test-export-invalid-transform": {
"properties": {
"title": { "type": "text" }
}
},
"apm-telemetry": {
"properties": {
"has_any_services": {
"type": "boolean"
},
"services_per_agent": {
"properties": {
"go": {
"type": "long",
"null_value": 0
},
"java": {
"type": "long",
"null_value": 0
},
"js-base": {
"type": "long",
"null_value": 0
},
"nodejs": {
"type": "long",
"null_value": 0
},
"python": {
"type": "long",
"null_value": 0
},
"ruby": {
"type": "long",
"null_value": 0
}
}
}
}
},
"canvas-workpad": {
"dynamic": "false",
"properties": {
"@created": {
"type": "date"
},
"@timestamp": {
"type": "date"
},
"id": {
"type": "text",
"index": false
},
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
}
}
},
"config": {
"dynamic": "true",
"properties": {
"accessibility:disableAnimations": {
"type": "boolean"
},
"buildNum": {
"type": "keyword"
},
"dateFormat:tz": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"defaultIndex": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"telemetry:optIn": {
"type": "boolean"
}
}
},
"dashboard": {
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"optionsJSON": {
"type": "text"
},
"panelsJSON": {
"type": "text"
},
"refreshInterval": {
"properties": {
"display": {
"type": "keyword"
},
"pause": {
"type": "boolean"
},
"section": {
"type": "integer"
},
"value": {
"type": "integer"
}
}
},
"timeFrom": {
"type": "keyword"
},
"timeRestore": {
"type": "boolean"
},
"timeTo": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"map": {
"properties": {
"bounds": {
"dynamic": false,
"properties": {}
},
"description": {
"type": "text"
},
"layerListJSON": {
"type": "text"
},
"mapStateJSON": {
"type": "text"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"graph-workspace": {
"properties": {
"description": {
"type": "text"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"numLinks": {
"type": "integer"
},
"numVertices": {
"type": "integer"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
},
"wsState": {
"type": "text"
}
}
},
"index-pattern": {
"properties": {
"fieldFormatMap": {
"type": "text"
},
"fields": {
"type": "text"
},
"intervalName": {
"type": "keyword"
},
"notExpandable": {
"type": "boolean"
},
"sourceFilters": {
"type": "text"
},
"timeFieldName": {
"type": "keyword"
},
"title": {
"type": "text"
},
"type": {
"type": "keyword"
},
"typeMeta": {
"type": "keyword"
}
}
},
"kql-telemetry": {
"properties": {
"optInCount": {
"type": "long"
},
"optOutCount": {
"type": "long"
}
}
},
"migrationVersion": {
"dynamic": "true",
"properties": {
"index-pattern": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"space": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
},
"namespace": {
"type": "keyword"
},
"search": {
"properties": {
"columns": {
"type": "keyword"
},
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"sort": {
"type": "keyword"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"server": {
"properties": {
"uuid": {
"type": "keyword"
}
}
},
"space": {
"properties": {
"_reserved": {
"type": "boolean"
},
"color": {
"type": "keyword"
},
"description": {
"type": "text"
},
"disabledFeatures": {
"type": "keyword"
},
"initials": {
"type": "keyword"
},
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 2048
}
}
}
}
},
"spaceId": {
"type": "keyword"
},
"telemetry": {
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"timelion-sheet": {
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"timelion_chart_height": {
"type": "integer"
},
"timelion_columns": {
"type": "integer"
},
"timelion_interval": {
"type": "keyword"
},
"timelion_other_interval": {
"type": "keyword"
},
"timelion_rows": {
"type": "integer"
},
"timelion_sheet": {
"type": "text"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"type": {
"type": "keyword"
},
"updated_at": {
"type": "date"
},
"url": {
"properties": {
"accessCount": {
"type": "long"
},
"accessDate": {
"type": "date"
},
"createDate": {
"type": "date"
},
"url": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 2048
}
}
}
}
},
"visualization": {
"properties": {
"description": {
"type": "text"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"savedSearchId": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
},
"visState": {
"type": "text"
}
}
},
"references": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "keyword"
},
"type": {
"type": "keyword"
}
},
"type": "nested"
}
}
}
}
}

View file

@ -19,122 +19,169 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
const esArchiver = getService('esArchiver');
describe('export transforms', () => {
before(async () => {
await esArchiver.load(
'../functional/fixtures/es_archiver/saved_objects_management/export_transform'
);
});
describe('root objects export transforms', () => {
before(async () => {
await esArchiver.load(
'../functional/fixtures/es_archiver/saved_objects_management/export_transform'
);
});
after(async () => {
await esArchiver.unload(
'../functional/fixtures/es_archiver/saved_objects_management/export_transform'
);
});
after(async () => {
await esArchiver.unload(
'../functional/fixtures/es_archiver/saved_objects_management/export_transform'
);
});
it('allows to mutate the objects during an export', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-transform'],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([
{
id: 'type_1-obj_1',
enabled: false,
},
{
id: 'type_1-obj_2',
enabled: false,
},
]);
});
});
it('allows to add additional objects to an export', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-export-add',
id: 'type_2-obj_1',
},
],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']);
});
});
it('allows to add additional objects to an export when exporting by type', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-add'],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql([
'type_2-obj_1',
'type_2-obj_2',
'type_dep-obj_1',
'type_dep-obj_2',
]);
});
});
it('returns a 400 when the type causes a transform error', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-transform-error'],
excludeExportDetails: true,
})
.expect(400)
.then((resp) => {
const { attributes, ...error } = resp.body;
expect(error).to.eql({
error: 'Bad Request',
message: 'Error transforming objects to export',
statusCode: 400,
it('allows to mutate the objects during an export', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-transform'],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([
{
id: 'type_1-obj_1',
enabled: false,
},
{
id: 'type_1-obj_2',
enabled: false,
},
]);
});
expect(attributes.cause).to.eql('Error during transform');
expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']);
});
});
it('allows to add additional objects to an export', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-export-add',
id: 'type_2-obj_1',
},
],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']);
});
});
it('allows to add additional objects to an export when exporting by type', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-add'],
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text);
expect(objects.map((obj) => obj.id)).to.eql([
'type_2-obj_1',
'type_2-obj_2',
'type_dep-obj_1',
'type_dep-obj_2',
]);
});
});
it('returns a 400 when the type causes a transform error', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-transform-error'],
excludeExportDetails: true,
})
.expect(400)
.then((resp) => {
const { attributes, ...error } = resp.body;
expect(error).to.eql({
error: 'Bad Request',
message: 'Error transforming objects to export',
statusCode: 400,
});
expect(attributes.cause).to.eql('Error during transform');
expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']);
});
});
it('returns a 400 when the type causes an invalid transform', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-invalid-transform'],
excludeExportDetails: true,
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
error: 'Bad Request',
message: 'Invalid transform performed on objects to export',
statusCode: 400,
attributes: {
objectKeys: ['test-export-invalid-transform|type_3-obj_1'],
},
});
});
});
});
it('returns a 400 when the type causes an invalid transform', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
type: ['test-export-invalid-transform'],
excludeExportDetails: true,
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
error: 'Bad Request',
message: 'Invalid transform performed on objects to export',
statusCode: 400,
attributes: {
objectKeys: ['test-export-invalid-transform|type_3-obj_1'],
},
describe('FOO nested export transforms', () => {
before(async () => {
await esArchiver.load(
'../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform'
);
});
after(async () => {
await esArchiver.unload(
'../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform'
);
});
it('execute export transforms for reference objects', async () => {
await supertest
.post('/api/saved_objects/_export')
.set('kbn-xsrf', 'true')
.send({
objects: [
{
type: 'test-export-transform',
id: 'type_1-obj_1',
},
],
includeReferencesDeep: true,
excludeExportDetails: true,
})
.expect(200)
.then((resp) => {
const objects = parseNdJson(resp.text).sort((obj1, obj2) =>
obj1.id.localeCompare(obj2.id)
);
expect(objects.map((obj) => obj.id)).to.eql([
'type_1-obj_1',
'type_1-obj_2',
'type_2-obj_1',
'type_dep-obj_1',
]);
expect(objects[0].attributes.enabled).to.eql(false);
expect(objects[1].attributes.enabled).to.eql(false);
});
});
});
});
});
}