diff --git a/changelogs/fragments/9226.yml b/changelogs/fragments/9226.yml new file mode 100644 index 000000000000..bbedf3205596 --- /dev/null +++ b/changelogs/fragments/9226.yml @@ -0,0 +1,2 @@ +feat: +- Enable maximum workspaces ([#9226](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/9226)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index d247737f3288..31dd5a583afd 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -353,6 +353,10 @@ # overrides: # "home:useNewHomePage": true +# Set a maximum number of workspaces +# by default, there is no limit. +# workspace.maximum_workspaces: 100 + # 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. diff --git a/src/plugins/workspace/config.ts b/src/plugins/workspace/config.ts index 79412f5c02ee..3e443a2fbfe1 100644 --- a/src/plugins/workspace/config.ts +++ b/src/plugins/workspace/config.ts @@ -7,6 +7,7 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), + maximum_workspaces: schema.maybe(schema.number()), }); export type ConfigSchema = TypeOf; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 82e84e714dbd..142976509dc8 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -55,6 +55,7 @@ import { WorkspaceUiSettingsClientWrapper } from './saved_objects/workspace_ui_s import { uiSettings } from './ui_settings'; import { RepositoryWrapper } from './saved_objects/repository_wrapper'; import { DataSourcePluginSetup } from '../../data_source/server'; +import { ConfigSchema } from '../config'; export interface WorkspacePluginDependencies { dataSource: DataSourcePluginSetup; @@ -68,6 +69,7 @@ export class WorkspacePlugin implements Plugin; private workspaceSavedObjectsClientWrapper?: WorkspaceSavedObjectsClientWrapper; private workspaceUiSettingsClientWrapper?: WorkspaceUiSettingsClientWrapper; + private workspaceConfig$: Observable; private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** @@ -215,18 +217,22 @@ export class WorkspacePlugin implements Plugin ({ { type: 'data-connection', id: 'id1' }, { type: 'data-connection', id: 'id2' }, ]), - checkAndSetDefaultDataSource: (...args) => mockCheckAndSetDefaultDataSource(...args), + checkAndSetDefaultDataSource: (...args: [IUiSettingsClient, string[], boolean]) => + mockCheckAndSetDefaultDataSource(...args), })); describe('#WorkspaceClient', () => { @@ -125,6 +130,38 @@ describe('#WorkspaceClient', () => { ); }); + it('create# should call find when maximum workspaces are set', async () => { + const client = new WorkspaceClient(coreSetup, logger, { + maximum_workspaces: 1, + }); + client?.setSavedObjects(savedObjects); + + find.mockImplementation((findParams) => { + if (!findParams.search) { + return { + total: 1, + }; + } + + return {}; + }); + + const createResult = await client.create(mockRequestDetail, { + name: mockWorkspaceName, + permissions: {}, + dataSources: [], + dataConnections: [], + }); + + expect(createResult).toEqual({ + success: false, + error: 'Maximum number of workspaces (1) reached', + }); + + expect(find).toHaveBeenCalledTimes(2); + find.mockClear(); + }); + it('update# should not call addToWorkspaces if no new data sources and data connections added', async () => { const client = new WorkspaceClient(coreSetup, logger); await client.setup(coreSetup); diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 493bb664d5c0..39f6585c21aa 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -33,6 +33,7 @@ import { DATA_SOURCE_SAVED_OBJECT_TYPE, DATA_CONNECTION_SAVED_OBJECT_TYPE, } from '../../data_source/common'; +import { ConfigSchema } from '../config'; const WORKSPACE_ID_SIZE = 6; @@ -44,15 +45,21 @@ const WORKSPACE_NOT_FOUND_ERROR = i18n.translate('workspace.notFound.error', { defaultMessage: 'workspace not found', }); +interface ConfigType { + maximum_workspaces?: ConfigSchema['maximum_workspaces']; +} + export class WorkspaceClient implements IWorkspaceClientImpl { private setupDep: CoreSetup; private logger: Logger; private savedObjects?: SavedObjectsServiceStart; private uiSettings?: UiSettingsServiceStart; + private config?: ConfigType; - constructor(core: CoreSetup, logger: Logger) { + constructor(core: CoreSetup, logger: Logger, config?: ConfigType) { this.setupDep = core; this.logger = logger; + this.config = config; } private getScopedClientWithoutPermission( @@ -115,17 +122,32 @@ export class WorkspaceClient implements IWorkspaceClientImpl { const { permissions, dataSources, dataConnections, ...attributes } = payload; const id = generateRandomId(WORKSPACE_ID_SIZE); const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); - const existingWorkspaceRes = await this.getScopedClientWithoutPermission(requestDetail)?.find( - { - type: WORKSPACE_TYPE, - search: `"${attributes.name}"`, - searchFields: ['name'], - } - ); + const clientWithoutPermission = this.getScopedClientWithoutPermission(requestDetail); + const existingWorkspaceRes = await clientWithoutPermission?.find({ + type: WORKSPACE_TYPE, + search: `"${attributes.name}"`, + searchFields: ['name'], + }); if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } + if (this.config?.maximum_workspaces) { + const workspaces = await clientWithoutPermission?.find({ + type: WORKSPACE_TYPE, + }); + if (workspaces && workspaces.total >= this.config.maximum_workspaces) { + throw new Error( + i18n.translate('workspace.maximum.error', { + defaultMessage: 'Maximum number of workspaces ({length}) reached', + values: { + length: this.config.maximum_workspaces, + }, + }) + ); + } + } + const promises = []; if (dataSources) {