Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[saved objects] enable deletion of saved objects by type if configured #6443

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/6443.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Adds `migrations.delete` to delete saved objects by type during a migration ([#6443](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6443))
8 changes: 7 additions & 1 deletion config/opensearch_dashboards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,12 @@
# Set the value to true to enable workspace feature
# workspace.enabled: false

# Optional settings to specify saved object types to be deleted during migration.
# This feature can help address compatibility issues that may arise during the migration of saved objects, such as types defined by legacy applications.
# Please note, using this feature carries a risk. Deleting saved objects during migration could potentially lead to unintended data loss. Use with caution.
# migrations.delete.enabled: false
# migrations.delete.types: []

# Set the value to true to enable Ui Metric Collectors in Usage Collector
# This publishes the Application Usage and UI Metrics into the saved object, which can be accessed by /api/stats?extended=true&legacy=true&exclude_usage=false
# usageCollection.uiMetric.enabled: false
# usageCollection.uiMetric.enabled: false
222 changes: 222 additions & 0 deletions src/core/server/saved_objects/migrations/core/index_migrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,228 @@ describe('IndexMigrator', () => {
});
});

test('deletes saved objects by type if configured', async () => {
const { client } = testOpts;

const deleteType = 'delete_type';

const rawConfig = configMock.create();
rawConfig.get.mockImplementation((path) => {
if (path === 'migrations.delete.enabled') {
return true;
}
if (path === 'migrations.delete.types') {
return [deleteType];
}
});
testOpts.opensearchDashboardsRawConfig = rawConfig;

testOpts.mappingProperties = { foo: { type: 'text' } as any };

withIndex(client, {
index: {
'.kibana_1': {
aliases: {},
mappings: {
properties: {
delete_type: { properties: { type: deleteType } },
},
},
},
},
});

await new IndexMigrator(testOpts).migrate();

expect(client.indices.create).toHaveBeenCalledWith({
body: {
mappings: {
dynamic: 'strict',
_meta: {
migrationMappingPropertyHashes: {
foo: '625b32086eb1d1203564cf85062dd22e',
migrationVersion: '4a1746014a75ade3a714e1db5763276f',
namespace: '2f4316de49999235636386fe51dc06c1',
namespaces: '2f4316de49999235636386fe51dc06c1',
originId: '2f4316de49999235636386fe51dc06c1',
references: '7997cf5a56cc02bdc9c93361bde732b0',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
},
},
properties: {
foo: { type: 'text' },
migrationVersion: { dynamic: 'true', type: 'object' },
namespace: { type: 'keyword' },
namespaces: { type: 'keyword' },
originId: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
references: {
type: 'nested',
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
},
index: '.kibana_2',
});
});

test('retains saved objects by type if delete is not enabled', async () => {
const { client } = testOpts;

const deleteType = 'delete_type';

const rawConfig = configMock.create();
rawConfig.get.mockImplementation((path) => {
if (path === 'migrations.delete.enabled') {
return false;
}
if (path === 'migrations.delete.types') {
return [deleteType];
}
});
testOpts.opensearchDashboardsRawConfig = rawConfig;

testOpts.mappingProperties = { foo: { type: 'text' } as any };

withIndex(client, {
index: {
'.kibana_1': {
aliases: {},
mappings: {
properties: {
delete_type: { properties: { type: deleteType } },
},
},
},
},
});

await new IndexMigrator(testOpts).migrate();

expect(client.indices.create).toHaveBeenCalledWith({
body: {
mappings: {
dynamic: 'strict',
_meta: {
migrationMappingPropertyHashes: {
foo: '625b32086eb1d1203564cf85062dd22e',
migrationVersion: '4a1746014a75ade3a714e1db5763276f',
namespace: '2f4316de49999235636386fe51dc06c1',
namespaces: '2f4316de49999235636386fe51dc06c1',
originId: '2f4316de49999235636386fe51dc06c1',
references: '7997cf5a56cc02bdc9c93361bde732b0',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
},
},
properties: {
delete_type: { dynamic: false, properties: {} },
foo: { type: 'text' },
migrationVersion: { dynamic: 'true', type: 'object' },
namespace: { type: 'keyword' },
namespaces: { type: 'keyword' },
originId: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
references: {
type: 'nested',
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
},
index: '.kibana_2',
});
});

test('retains saved objects by type if delete types does not exist', async () => {
const { client } = testOpts;

const deleteType = 'delete_type';
const retainType = 'retain_type';

const rawConfig = configMock.create();
rawConfig.get.mockImplementation((path) => {
if (path === 'migrations.delete.enabled') {
return true;
}
if (path === 'migrations.delete.types') {
return [deleteType];
}
});
testOpts.opensearchDashboardsRawConfig = rawConfig;

testOpts.mappingProperties = { foo: { type: 'text' } as any };

withIndex(client, {
index: {
'.kibana_1': {
aliases: {},
mappings: {
properties: {
retain_type: { properties: { type: retainType } },
},
},
},
},
});

await new IndexMigrator(testOpts).migrate();

expect(client.indices.create).toHaveBeenCalledWith({
body: {
mappings: {
dynamic: 'strict',
_meta: {
migrationMappingPropertyHashes: {
foo: '625b32086eb1d1203564cf85062dd22e',
migrationVersion: '4a1746014a75ade3a714e1db5763276f',
namespace: '2f4316de49999235636386fe51dc06c1',
namespaces: '2f4316de49999235636386fe51dc06c1',
originId: '2f4316de49999235636386fe51dc06c1',
references: '7997cf5a56cc02bdc9c93361bde732b0',
type: '2f4316de49999235636386fe51dc06c1',
updated_at: '00da57df13e94e9d98437d13ace4bfe0',
},
},
properties: {
retain_type: { dynamic: false, properties: {} },
foo: { type: 'text' },
migrationVersion: { dynamic: 'true', type: 'object' },
namespace: { type: 'keyword' },
namespaces: { type: 'keyword' },
originId: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
references: {
type: 'nested',
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
},
index: '.kibana_2',
});
});

test('points the alias at the dest index', async () => {
const { client } = testOpts;

Expand Down
29 changes: 29 additions & 0 deletions src/core/server/saved_objects/migrations/core/index_migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* under the License.
*/

import { DeleteByQueryRequest } from '@opensearch-project/opensearch/api/types';
import { diffMappings } from './build_active_mappings';
import * as Index from './opensearch_index';
import { migrateRawDocs } from './migrate_raw_docs';
Expand Down Expand Up @@ -123,6 +124,7 @@
const { client, alias, source, dest, log } = context;

await deleteIndexTemplates(context);
await deleteSavedObjectsByType(context);

log.info(`Creating index ${dest.indexName}.`);

Expand Down Expand Up @@ -171,6 +173,33 @@
return Promise.all(templateNames.map((name) => client.indices.deleteTemplate({ name: name! })));
}

/**
* Delete saved objects by type. If migrations.delete.types is specified,
* any saved objects that matches that type will be deleted.
*/
async function deleteSavedObjectsByType(context: Context) {
const { client, source, log, typesToDelete } = context;
if (!source.exists || !typesToDelete || typesToDelete.length === 0) {
return;
}

log.info(`Removing saved objects of types: ${typesToDelete.join(', ')}`);
const params = {

Check warning on line 187 in src/core/server/saved_objects/migrations/core/index_migrator.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/migrations/core/index_migrator.ts#L186-L187

Added lines #L186 - L187 were not covered by tests
index: source.indexName,
body: {
query: {
bool: {
should: [...typesToDelete.map((type) => ({ term: { type } }))],

Check warning on line 192 in src/core/server/saved_objects/migrations/core/index_migrator.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/migrations/core/index_migrator.ts#L192

Added line #L192 was not covered by tests
},
},
},
conflicts: 'proceed',
refresh: true,
} as DeleteByQueryRequest;
log.debug(`Delete by query params: ${JSON.stringify(params)}`);
return client.deleteByQuery(params);

Check warning on line 200 in src/core/server/saved_objects/migrations/core/index_migrator.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/saved_objects/migrations/core/index_migrator.ts#L199-L200

Added lines #L199 - L200 were not covered by tests
}

/**
* Moves all docs from sourceIndex to destIndex, migrating each as necessary.
* This moves documents from the concrete index, rather than the alias, to prevent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
* under the License.
*/

import { disableUnknownTypeMappingFields } from './migration_context';
import { disableUnknownTypeMappingFields, deleteTypeMappingsFields } from './migration_context';
import { configMock } from '../../../config/mocks';

describe('disableUnknownTypeMappingFields', () => {
const sourceMappings = {
Expand Down Expand Up @@ -97,3 +98,87 @@ describe('disableUnknownTypeMappingFields', () => {
});
});
});

describe('deleteTypeMappingsFields', () => {
it('should delete specified type mappings fields', () => {
const targetMappings = {
properties: {
type1: { type: 'text' },
type2: { type: 'keyword' },
type3: { type: 'boolean' },
},
} as const;

const rawConfig = configMock.create();
rawConfig.get.mockImplementation((path) => {
if (path === 'migrations.delete.enabled') {
return true;
}
if (path === 'migrations.delete.types') {
return ['type1', 'type3'];
}
});

const updatedMappings = deleteTypeMappingsFields(targetMappings, rawConfig);

expect(updatedMappings.properties).toEqual({
type2: { type: 'keyword' },
});
});

it('should not delete any type mappings fields if delete is not enabled', () => {
const targetMappings = {
properties: {
type1: { type: 'text' },
type2: { type: 'keyword' },
type3: { type: 'boolean' },
},
} as const;

const rawConfig = configMock.create();
rawConfig.get.mockImplementation((path) => {
if (path === 'migrations.delete.enabled') {
return false;
}
if (path === 'migrations.delete.types') {
return ['type1', 'type3'];
}
});

const updatedMappings = deleteTypeMappingsFields(targetMappings, rawConfig);

expect(updatedMappings.properties).toEqual({
type1: { type: 'text' },
type2: { type: 'keyword' },
type3: { type: 'boolean' },
});
});

it('should not delete any type mappings fields if delete types are not specified', () => {
const targetMappings = {
properties: {
type1: { type: 'text' },
type2: { type: 'keyword' },
type3: { type: 'boolean' },
},
} as const;

const rawConfig = configMock.create();
rawConfig.get.mockImplementation((path) => {
if (path === 'migrations.delete.enabled') {
return true;
}
if (path === 'migrations.delete.types') {
return [];
}
});

const updatedMappings = deleteTypeMappingsFields(targetMappings, rawConfig);

expect(updatedMappings.properties).toEqual({
type1: { type: 'text' },
type2: { type: 'keyword' },
type3: { type: 'boolean' },
});
});
});
Loading
Loading