diff --git a/package-lock.json b/package-lock.json index 57cdb448135..66bc1623198 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44872,6 +44872,7 @@ "@mongodb-js/compass-logging": "^1.4.8", "@mongodb-js/compass-telemetry": "^1.2.0", "hadron-app-registry": "^9.2.7", + "lodash": "^4.17.21", "mongodb-ns": "^2.4.2", "react": "^17.0.2", "react-redux": "^8.1.3", @@ -56613,6 +56614,7 @@ "depcheck": "^1.4.1", "eslint": "^7.25.0", "hadron-app-registry": "^9.2.7", + "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb-ns": "^2.4.2", "nyc": "^15.1.0", diff --git a/packages/compass-global-writes/package.json b/packages/compass-global-writes/package.json index ab2f3f13335..cd3b029ccaa 100644 --- a/packages/compass-global-writes/package.json +++ b/packages/compass-global-writes/package.json @@ -56,6 +56,7 @@ "@mongodb-js/compass-logging": "^1.4.8", "@mongodb-js/compass-telemetry": "^1.2.0", "hadron-app-registry": "^9.2.7", + "lodash": "^4.17.21", "@mongodb-js/compass-field-store": "^9.18.1", "mongodb-ns": "^2.4.2", "react": "^17.0.2", diff --git a/packages/compass-global-writes/src/components/index.spec.tsx b/packages/compass-global-writes/src/components/index.spec.tsx index e264d94197d..4aff0336345 100644 --- a/packages/compass-global-writes/src/components/index.spec.tsx +++ b/packages/compass-global-writes/src/components/index.spec.tsx @@ -5,25 +5,25 @@ import { GlobalWrites } from './index'; import { renderWithStore } from './../../tests/create-store'; describe('Compass GlobalWrites Plugin', function () { - it('renders plugin in NOT_READY state', function () { - renderWithStore(); + it('renders plugin in NOT_READY state', async function () { + await renderWithStore(); expect(screen.getByText(/loading/i)).to.exist; }); - it('renders plugin in UNSHARDED state', function () { - renderWithStore(); + it('renders plugin in UNSHARDED state', async function () { + await renderWithStore(); expect(screen.getByTestId('shard-collection-button')).to.exist; }); - it('renders plugin in SUBMITTING_FOR_SHARDING state', function () { - renderWithStore( + it('renders plugin in SUBMITTING_FOR_SHARDING state', async function () { + await renderWithStore( ); expect(screen.getByTestId('shard-collection-button')).to.exist; }); - it('renders plugin in SHARDING state', function () { - renderWithStore(); + it('renders plugin in SHARDING state', async function () { + await renderWithStore(); expect(screen.getByText(/sharding your collection/i)).to.exist; }); }); diff --git a/packages/compass-global-writes/src/components/index.tsx b/packages/compass-global-writes/src/components/index.tsx index ff0a5b02fa4..77aa286de46 100644 --- a/packages/compass-global-writes/src/components/index.tsx +++ b/packages/compass-global-writes/src/components/index.tsx @@ -10,6 +10,7 @@ import type { RootState, ShardingStatus } from '../store/reducer'; import { ShardingStatuses } from '../store/reducer'; import UnshardedState from './states/unsharded'; import ShardingState from './states/sharding'; +import ShardKeyCorrect from './states/shard-key-correct'; const containerStyles = css({ paddingLeft: spacing[400], @@ -58,6 +59,13 @@ function ShardingStateView({ return ; } + if ( + shardingStatus === ShardingStatuses.SHARD_KEY_CORRECT || + shardingStatus === ShardingStatuses.UNMANAGING_NAMESPACE + ) { + return ; + } + return null; } diff --git a/packages/compass-global-writes/src/components/shard-zones-table.spec.tsx b/packages/compass-global-writes/src/components/shard-zones-table.spec.tsx new file mode 100644 index 00000000000..5aae046e76e --- /dev/null +++ b/packages/compass-global-writes/src/components/shard-zones-table.spec.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { expect } from 'chai'; +import { render, screen, within } from '@mongodb-js/testing-library-compass'; +import { ShardZonesTable } from './shard-zones-table'; +import { type ShardZoneData } from '../store/reducer'; + +describe('Compass GlobalWrites Plugin', function () { + const shardZones: ShardZoneData[] = [ + { + zoneId: '45893084', + country: 'Germany', + readableName: 'Germany', + isoCode: 'DE', + typeOneIsoCode: 'DE', + zoneName: 'EMEA', + zoneLocations: ['Frankfurt'], + }, + { + zoneId: '43829408', + country: 'Germany', + readableName: 'Germany - Berlin', + isoCode: 'DE-BE', + typeOneIsoCode: 'DE', + zoneName: 'EMEA', + zoneLocations: ['Frankfurt'], + }, + ]; + + it('renders the Location name & Zone for all items', function () { + render(); + + const rows = screen.getAllByRole('row'); + expect(rows).to.have.lengthOf(3); // 1 header, 2 items + expect(within(rows[1]).getByText('Germany (DE)')).to.be.visible; + expect(within(rows[1]).getByText('EMEA (Frankfurt)')).to.be.visible; + expect(within(rows[2]).getByText('Germany - Berlin (DE-BE)')).to.be.visible; + expect(within(rows[2]).getByText('EMEA (Frankfurt)')).to.be.visible; + }); +}); diff --git a/packages/compass-global-writes/src/components/shard-zones-table.tsx b/packages/compass-global-writes/src/components/shard-zones-table.tsx new file mode 100644 index 00000000000..abc866c668d --- /dev/null +++ b/packages/compass-global-writes/src/components/shard-zones-table.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { + Table, + TableBody, + TableHead, + HeaderRow, + HeaderCell, + Row, + Cell, + css, +} from '@mongodb-js/compass-components'; +import type { ShardZoneData } from '../store/reducer'; + +const containerStyles = css({ + maxWidth: '700px', + height: '400px', +}); + +export function ShardZonesTable({ + shardZones, +}: { + shardZones: ShardZoneData[]; +}) { + return ( + // TODO(COMPASS-8336): + // Add search + // group zones by ShardZoneData.typeOneIsoCode + // and display them in a single row that can be expanded + + + + Location Name + Zone + + + + {shardZones.map( + ({ readableName, zoneName, zoneLocations, isoCode }, index) => { + return ( + + + {readableName} ({isoCode}) + + + {zoneName} ({zoneLocations.join(', ')}) + + + ); + } + )} + +
+ ); +} diff --git a/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx b/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx new file mode 100644 index 00000000000..86f938cb10a --- /dev/null +++ b/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { expect } from 'chai'; +import { screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { + ShardKeyCorrect, + type ShardKeyCorrectProps, +} from './shard-key-correct'; +import { type ShardZoneData } from '../../store/reducer'; +import Sinon from 'sinon'; +import { renderWithStore } from '../../../tests/create-store'; +import { type ConnectionInfo } from '@mongodb-js/compass-connections/provider'; + +describe('Compass GlobalWrites Plugin', function () { + const shardZones: ShardZoneData[] = [ + { + zoneId: '45893084', + country: 'Germany', + readableName: 'Germany', + isoCode: 'DE', + typeOneIsoCode: 'DE', + zoneName: 'EMEA', + zoneLocations: ['Frankfurt'], + }, + ]; + + const baseProps: ShardKeyCorrectProps = { + shardZones, + namespace: 'db1.coll1', + shardKey: { + fields: [ + { type: 'HASHED', name: 'location' }, + { type: 'RANGE', name: 'secondary' }, + ], + isUnique: false, + }, + isUnmanagingNamespace: false, + onUnmanageNamespace: () => {}, + }; + + function renderWithProps( + props?: Partial, + options?: Parameters[1] + ) { + return renderWithStore( + , + options + ); + } + + it('Provides button to unmanage', async function () { + const onUnmanageNamespace = Sinon.spy(); + await renderWithProps({ onUnmanageNamespace }); + + const btn = await screen.findByRole('button', { + name: /Unmanage collection/, + }); + expect(btn).to.be.visible; + + userEvent.click(btn); + + expect(onUnmanageNamespace).to.have.been.calledOnce; + }); + + it('Unmanage btn is disabled when the action is in progress', async function () { + const onUnmanageNamespace = Sinon.spy(); + await renderWithProps({ onUnmanageNamespace, isUnmanagingNamespace: true }); + + const btn = await screen.findByTestId( + 'shard-collection-button' + ); + expect(btn).to.be.visible; + expect(btn.getAttribute('aria-disabled')).to.equal('true'); + + userEvent.click(btn); + + expect(onUnmanageNamespace).not.to.have.been.called; + }); + + it('Provides link to Edit Configuration', async function () { + const connectionInfo = { + id: 'testConnection', + connectionOptions: { + connectionString: 'mongodb://test', + }, + atlasMetadata: { + projectId: 'project1', + clusterName: 'myCluster', + } as ConnectionInfo['atlasMetadata'], + }; + await renderWithProps(undefined, { + connectionInfo, + }); + + const link = await screen.findByRole('link', { + name: /Edit Configuration/, + }); + const expectedHref = `/v2/${connectionInfo.atlasMetadata?.projectId}#/clusters/edit/${connectionInfo.atlasMetadata?.clusterName}`; + + expect(link).to.be.visible; + expect(link).to.have.attribute('href', expectedHref); + }); + + it('Describes the shardKey', async function () { + await renderWithProps(); + + const title = await screen.findByTestId('shardkey-description-title'); + expect(title).to.be.visible; + expect(title.textContent).to.equal( + `${baseProps.namespace} is configured with the following shard key:` + ); + const list = await screen.findByTestId('shardkey-description-content'); + expect(list).to.be.visible; + expect(list.textContent).to.contain(`"location", "secondary"`); + }); + + it('Contains sample codes', async function () { + await renderWithProps(); + + const findingDocumentsSample = await screen.findByTestId( + 'sample-finding-documents' + ); + expect(findingDocumentsSample).to.be.visible; + expect(findingDocumentsSample.textContent).to.contain( + `use db1db["coll1"].find({"location": "US-NY", "secondary": ""})` + ); + + const insertingDocumentsSample = await screen.findByTestId( + 'sample-inserting-documents' + ); + expect(insertingDocumentsSample).to.be.visible; + expect(insertingDocumentsSample.textContent).to.contain( + `use db1db["coll1"].insertOne({"location": "US-NY", "secondary": "",...})` + ); + }); +}); diff --git a/packages/compass-global-writes/src/components/states/shard-key-correct.tsx b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx new file mode 100644 index 00000000000..05a29add5a8 --- /dev/null +++ b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx @@ -0,0 +1,210 @@ +import React, { useMemo } from 'react'; +import { + Banner, + BannerVariant, + Body, + css, + Link, + spacing, + Code, + Subtitle, + Label, + Button, +} from '@mongodb-js/compass-components'; +import { connect } from 'react-redux'; +import { + ShardingStatuses, + unmanageNamespace, + type RootState, + type ShardKey, + type ShardZoneData, +} from '../../store/reducer'; +import toNS from 'mongodb-ns'; +import { ShardZonesTable } from '../shard-zones-table'; +import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; + +const nbsp = '\u00a0'; + +const containerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[400], + marginBottom: spacing[400], +}); + +const codeBlockContainerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[100], + maxWidth: '700px', +}); + +const paragraphStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[100], +}); + +export type ShardKeyCorrectProps = { + namespace: string; + shardKey?: ShardKey; + shardZones: ShardZoneData[]; + isUnmanagingNamespace: boolean; + onUnmanageNamespace: () => void; +}; + +export function ShardKeyCorrect({ + namespace, + shardKey, + shardZones, + isUnmanagingNamespace, + onUnmanageNamespace, +}: ShardKeyCorrectProps) { + if (!shardKey) { + throw new Error('Shard key not found in ShardKeyCorrect'); + } + + const customShardKeyField = useMemo(() => { + return shardKey.fields[1].name; + }, [shardKey]); + + const { atlasMetadata } = useConnectionInfo(); + + const sampleCodes = useMemo(() => { + const { collection, database } = toNS(namespace); + return { + findingDocuments: `use ${database}\ndb[${JSON.stringify( + collection + )}].find({"location": "US-NY", "${customShardKeyField}": ""})`, + insertingDocuments: `use ${database}\ndb[${JSON.stringify( + collection + )}].insertOne({"location": "US-NY", "${customShardKeyField}": "",...})`, + }; + }, [namespace, customShardKeyField]); + + return ( +
+ + + All documents in your collection should contain both the ‘location’ + field (with a ISO country or subdivision code) and your{' '} + {customShardKeyField} field at insert time. + + {nbsp}We have included a table for reference below. + + +
+ + {namespace} is configured with the following shard + key: + + + {shardKey.fields.map((field) => `"${field.name}"`).join(', ')} + +
+ + Example commands +
+ + Start querying your database with some of the most{' '} + + common commands + {' '} + for Global Writes. + + + Replace the text to perform operations on different documents. US-NY + is an ISO 3166 location code referring to New York, United States. You + can look up other ISO 3166 location codes below. + +
+ +
+ + + {sampleCodes.findingDocuments} + +
+ +
+ + + {sampleCodes.insertingDocuments} + +
+ + Location Codes +
+ + Each document’s first field should include an ISO 3166-1 Alpha-2 code + for the location it belongs to. + + + We also support ISO 3166-2 subdivision codes for countries containing + a cloud provider data center (both ISO 3166-1 and ISO 3166-2 codes may + be used for these countries). All valid country codes and the zones to + which they map are listed in the table below. Additionally, you can + view a list of all location codes{' '} + here. + + + {atlasMetadata?.projectId && atlasMetadata?.clusterName && ( + <> + Locations’ zone mapping can be changed by navigating to this + clusters{' '} + + Edit Configuration + {' '} + page and clicking the Configure Location Mappings’ link above the + map. + + )} + +
+ + + + Unmanage this collection + + Documents belonging to this collection will no longer be distributed + across the shards of your global clusters. + +
+ +
+
+ ); +} + +export default connect( + (state: RootState) => ({ + namespace: state.namespace, + shardKey: state.shardKey, + shardZones: state.shardZones, + isUnmanagingNamespace: + state.status === ShardingStatuses.UNMANAGING_NAMESPACE, + }), + { + onUnmanageNamespace: unmanageNamespace, + } +)(ShardKeyCorrect); diff --git a/packages/compass-global-writes/src/components/states/sharding.spec.tsx b/packages/compass-global-writes/src/components/states/sharding.spec.tsx index beb6e372928..c3d8fb542be 100644 --- a/packages/compass-global-writes/src/components/states/sharding.spec.tsx +++ b/packages/compass-global-writes/src/components/states/sharding.spec.tsx @@ -11,8 +11,8 @@ function renderWithProps( } describe('Sharding', function () { - it('renders the info banner', function () { - renderWithProps(); + it('renders the info banner', async function () { + await renderWithProps(); expect(screen.getByRole('alert')).to.exist; }); }); diff --git a/packages/compass-global-writes/src/components/states/usharded.spec.tsx b/packages/compass-global-writes/src/components/states/usharded.spec.tsx index a26d5b228de..74d57ccb925 100644 --- a/packages/compass-global-writes/src/components/states/usharded.spec.tsx +++ b/packages/compass-global-writes/src/components/states/usharded.spec.tsx @@ -34,21 +34,21 @@ function setShardingKeyFieldValue(value: string) { } describe('UnshardedState', function () { - it('renders the warning banner', function () { - renderWithProps(); + it('renders the warning banner', async function () { + await renderWithProps(); expect(screen.getByRole('alert')).to.exist; }); - it('renders the text to the user', function () { - renderWithProps(); + it('renders the text to the user', async function () { + await renderWithProps(); expect(screen.getByTestId('unsharded-text-description')).to.exist; }); context('shard collection form', function () { let onCreateShardKeySpy: sinon.SinonSpy; - beforeEach(function () { + beforeEach(async function () { onCreateShardKeySpy = sinon.spy(); - renderWithProps({ onCreateShardKey: onCreateShardKeySpy }); + await renderWithProps({ onCreateShardKey: onCreateShardKeySpy }); }); it('renders location form field as disabled', function () { diff --git a/packages/compass-global-writes/src/plugin-title.tsx b/packages/compass-global-writes/src/plugin-title.tsx index e7fd917d6d2..0c484aecaac 100644 --- a/packages/compass-global-writes/src/plugin-title.tsx +++ b/packages/compass-global-writes/src/plugin-title.tsx @@ -72,7 +72,7 @@ const PluginTitle = ({ showWarning }: { showWarning: boolean }) => { }; export const GlobalWritesTabTitle = connect( - ({ isNamespaceSharded, status }: RootState) => ({ - showWarning: !isNamespaceSharded && status !== ShardingStatuses.NOT_READY, + ({ managedNamespace, status }: RootState) => ({ + showWarning: !managedNamespace && status !== ShardingStatuses.NOT_READY, }) )(PluginTitle); diff --git a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts index d548eae05c3..db41cb5f24f 100644 --- a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts +++ b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts @@ -1,8 +1,16 @@ import toNS from 'mongodb-ns'; +import keyBy from 'lodash/keyBy'; import type { AtlasService } from '@mongodb-js/atlas-service/provider'; import type { CreateShardKeyData } from '../store/reducer'; +import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; -type ZoneMapping = unknown; +export type ShardZoneMapping = { + isoCode: string; + typeOneIsoCode: string; + zoneId: string; + country: string; + readableName: string; +}; export type ManagedNamespace = { db: string; collection: string; @@ -14,18 +22,42 @@ export type ManagedNamespace = { }; type GeoShardingData = { - customZoneMapping: Record; + customZoneMapping: Record; managedNamespaces: ManagedNamespace[]; selfManagedSharding: boolean; }; -type ClusterDetailsApiResponse = { +type ReplicationItem = { + id: string; + regionConfigs: { + regionView: { + location: string; + }; + }[]; + zoneId: string; + zoneName: string; +}; +export type ClusterDetailsApiResponse = { geoSharding: GeoShardingData; + replicationSpecList: ReplicationItem[]; +}; + +type AutomationAgentProcess = { + statusType: string; + workingOnShort: string; + errorText: string; }; -type AtlasCluterInfo = { - projectId: string; - clusterName: string; +export type AutomationAgentDeploymentStatusApiResponse = { + automationStatus: { + processes: AutomationAgentProcess[]; + }; +}; + +type AtlasShardKey = { + _id: string; + unique: boolean; + key: Record; }; function assertDataIsClusterDetailsApiResponse( @@ -41,15 +73,44 @@ function assertDataIsClusterDetailsApiResponse( 'Invalid cluster details API response geoSharding.customZoneMapping' ); } + if (!Array.isArray(data?.replicationSpecList)) { + throw new Error('Invalid cluster details API response replicationSpecList'); + } +} + +function assertDataIsAutomationAgentDeploymentStatusApiResponse( + data: any +): asserts data is AutomationAgentDeploymentStatusApiResponse { + if (!Array.isArray(data?.automationStatus?.processes)) { + throw new Error( + 'Invalid automation agent deployment status API response automationStatus.processes' + ); + } +} + +function assertDataIsShardZonesApiResponse( + data: any +): asserts data is Record { + if (typeof data !== 'object') { + throw new Error('Invalid shard zones API response'); + } } export class AtlasGlobalWritesService { - constructor(private atlasService: AtlasService) {} + constructor( + private atlasService: AtlasService, + private connectionInfo: ConnectionInfoRef + ) {} + + private getAtlasMetadata() { + if (!this.connectionInfo.current?.atlasMetadata) { + throw new Error('Atlas metadata is not available'); + } + return this.connectionInfo.current.atlasMetadata; + } - private async fetchClusterDetails({ - clusterName, - projectId, - }: AtlasCluterInfo): Promise { + private async getClusterDetails(): Promise { + const { projectId, clusterName } = this.getAtlasMetadata(); const uri = this.atlasService.cloudEndpoint( `nds/clusters/${projectId}/${clusterName}` ); @@ -59,13 +120,10 @@ export class AtlasGlobalWritesService { return clusterDetails; } - async isNamespaceManaged( - namespace: string, - atlasClusterInfo: AtlasCluterInfo - ) { - const clusterDetails = await this.fetchClusterDetails(atlasClusterInfo); + async getManagedNamespace(namespace: string) { + const clusterDetails = await this.getClusterDetails(); const { database, collection } = toNS(namespace); - return clusterDetails.geoSharding.managedNamespaces.some( + return clusterDetails.geoSharding.managedNamespaces.find( (managedNamespace) => { return ( managedNamespace.db === database && @@ -75,12 +133,8 @@ export class AtlasGlobalWritesService { ); } - async createShardKey( - namespace: string, - keyData: CreateShardKeyData, - atlasClusterInfo: AtlasCluterInfo - ) { - const clusterDetails = await this.fetchClusterDetails(atlasClusterInfo); + async createShardKey(namespace: string, keyData: CreateShardKeyData) { + const clusterDetails = await this.getClusterDetails(); const { database, collection } = toNS(namespace); const requestData: GeoShardingData = { ...clusterDetails.geoSharding, @@ -94,8 +148,134 @@ export class AtlasGlobalWritesService { ], }; + const { projectId, clusterName } = this.getAtlasMetadata(); const uri = this.atlasService.cloudEndpoint( - `nds/clusters/${atlasClusterInfo.projectId}/${atlasClusterInfo.clusterName}/geoSharding` + `nds/clusters/${projectId}/${clusterName}/geoSharding` + ); + + const response = await this.atlasService.authenticatedFetch(uri, { + method: 'PATCH', + body: JSON.stringify(requestData), + headers: { + 'Content-Type': 'application/json', + }, + }); + assertDataIsClusterDetailsApiResponse(await response.json()); + + const managedNamespace = requestData.managedNamespaces.find( + (managedNamespace) => + managedNamespace.db === database && + managedNamespace.collection === collection + ); + if (!managedNamespace) { + throw new Error('Managed namespace not found'); + } + return managedNamespace; + } + + async getShardingError(namespace: string) { + const { projectId } = this.getAtlasMetadata(); + const uri = this.atlasService.cloudEndpoint( + `/automation/deploymentStatus/${projectId}` + ); + const response = await this.atlasService.authenticatedFetch(uri); + const data = await response.json(); + assertDataIsAutomationAgentDeploymentStatusApiResponse(data); + const namespaceShardingError = data.automationStatus.processes.find( + (process) => + process.statusType === 'ERROR' && + process.workingOnShort === 'ShardingCollections' && + process.errorText.indexOf(namespace) !== -1 + ); + return namespaceShardingError?.errorText; + } + + async getShardingKeys(namespace: string) { + const { database: db, collection } = toNS(namespace); + const atlasMetadata = this.getAtlasMetadata(); + + const req = await this.atlasService.automationAgentRequest( + atlasMetadata, + 'getShardKey', + { + db, + collection, + } + ); + + if (!req) { + throw new Error( + 'Unexpected response from the automation agent backend: expected to get the request metadata, got undefined' + ); + } + + const res = await this.atlasService.automationAgentAwait( + atlasMetadata, + req.requestType, + req._id + ); + const data = res.response; + + if (data.length === 0) { + return null; + } + const { key, unique } = data[0]; + + return { + fields: Object.keys(key).map( + (field) => + ({ + name: field, + type: key[field] === 'hashed' ? 'HASHED' : 'RANGE', + } as const) + ), + isUnique: !!unique, + }; + } + + async getShardingZones() { + const { projectId } = this.getAtlasMetadata(); + const { + replicationSpecList: replicationSpecs, + geoSharding: { customZoneMapping }, + } = await this.getClusterDetails(); + + const uri = this.atlasService.cloudEndpoint( + `/nds/geoSharding/${projectId}/newFormLocationMapping` + ); + const response = await this.atlasService.authenticatedFetch(uri, { + method: 'POST', + body: JSON.stringify({ + replicationSpecs, + customZoneMapping, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const data = await response.json(); + assertDataIsShardZonesApiResponse(data); + return transformZoneData(Object.values(data), replicationSpecs); + } + + async unmanageNamespace(namespace: string) { + const clusterDetails = await this.getClusterDetails(); + const { database, collection } = toNS(namespace); + + const newManagedNamespaces = + clusterDetails.geoSharding.managedNamespaces.filter( + (managedNamespace) => + managedNamespace.db !== database || + managedNamespace.collection !== collection + ); + const requestData: GeoShardingData = { + ...clusterDetails.geoSharding, + managedNamespaces: newManagedNamespaces, + }; + + const { projectId, clusterName } = this.getAtlasMetadata(); + const uri = this.atlasService.cloudEndpoint( + `nds/clusters/${projectId}/${clusterName}/geoSharding` ); await this.atlasService.authenticatedFetch(uri, { @@ -107,3 +287,21 @@ export class AtlasGlobalWritesService { }); } } + +function transformZoneData( + zoneData: ShardZoneMapping[], + replicationSpecs: ReplicationItem[] +) { + const replicationSpecsMap = keyBy(replicationSpecs, 'zoneId'); + return zoneData.map((zone) => ({ + zoneId: zone.zoneId, + country: zone.country, + readableName: zone.readableName, + isoCode: zone.isoCode, + typeOneIsoCode: zone.typeOneIsoCode, + zoneName: replicationSpecsMap[zone.zoneId].zoneName, + zoneLocations: replicationSpecsMap[zone.zoneId].regionConfigs.map( + (regionConfig) => regionConfig.regionView.location + ), + })); +} diff --git a/packages/compass-global-writes/src/store/index.spec.ts b/packages/compass-global-writes/src/store/index.spec.ts index 643869dc71b..7aa4c56e6d6 100644 --- a/packages/compass-global-writes/src/store/index.spec.ts +++ b/packages/compass-global-writes/src/store/index.spec.ts @@ -7,12 +7,51 @@ import { type CreateShardKeyData, } from './reducer'; import sinon from 'sinon'; +import type { + AutomationAgentDeploymentStatusApiResponse, + ClusterDetailsApiResponse, + ManagedNamespace, + ShardZoneMapping, +} from '../services/atlas-global-writes-service'; +import { waitFor } from '@mongodb-js/testing-library-compass'; const DB = 'test'; const COLL = 'coll'; const NS = `${DB}.${COLL}`; -function createJsonResponse(data: any) { +const clusterDetails: ClusterDetailsApiResponse = { + geoSharding: { + customZoneMapping: {}, + managedNamespaces: [], + selfManagedSharding: false, + }, + replicationSpecList: [], +}; + +const managedNamespace: ManagedNamespace = { + db: DB, + collection: COLL, + customShardKey: 'secondary', + isCustomShardKeyHashed: false, + isShardKeyUnique: false, + numInitialChunks: null, + presplitHashedZones: false, +}; + +const shardKeyData: CreateShardKeyData = { + customShardKey: 'test', + isCustomShardKeyHashed: true, + isShardKeyUnique: false, + numInitialChunks: 1, + presplitHashedZones: true, +}; + +function createAuthFetchResponse< + TResponse extends + | ClusterDetailsApiResponse + | AutomationAgentDeploymentStatusApiResponse + | Record +>(data: TResponse) { return { json: () => Promise.resolve(data), }; @@ -36,108 +75,141 @@ describe('GlobalWritesStore Store', function () { expect(store.getState().status).to.equal('NOT_READY'); }); - context('actions', function () { - context('fetchClusterShardingData', function () { - it('when the namespace is not managed', async function () { - const store = createStore({ - authenticatedFetch: () => - createJsonResponse({ - geoSharding: { customZoneMapping: {}, managedNamespaces: [] }, - }), - }); - await store.dispatch(fetchClusterShardingData()); - expect(store.getState().status).to.equal('UNSHARDED'); - expect(store.getState().isNamespaceSharded).to.equal(false); + context('scenarios', function () { + it('not managed -> sharding', async function () { + const store = createStore({ + authenticatedFetch: () => createAuthFetchResponse(clusterDetails), }); - - // TODO (COMPASS-8277): Add more test for fetching shard key and process errors + await store.dispatch(fetchClusterShardingData()); + expect(store.getState().status).to.equal('UNSHARDED'); + expect(store.getState().managedNamespace).to.equal(undefined); + + const promise = store.dispatch(createShardKey(shardKeyData)); + expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING'); + await promise; + expect(store.getState().status).to.equal('SHARDING'); }); - context('createShardKey', function () { - const shardKeyData: CreateShardKeyData = { - customShardKey: 'test', - isCustomShardKeyHashed: true, - isShardKeyUnique: false, - numInitialChunks: 1, - presplitHashedZones: true, - }; - - it('sets SUBMITTING_FOR_SHARDING state when starting to create shard key and sets to SHARDING on success', async function () { - const store = createStore({ - authenticatedFetch: () => - createJsonResponse({ - geoSharding: { customZoneMapping: {}, managedNamespaces: [] }, - }), - }); - - const promise = store.dispatch(createShardKey(shardKeyData)); - expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING'); - - await promise; - expect(store.getState().status).to.equal('SHARDING'); + it('not managed -> failed sharding attempt', async function () { + const store = createStore({ + authenticatedFetch: (uri: string) => { + if (uri.includes('/geoSharding')) { + return Promise.reject(new Error('Failed to shard')); + } + + return createAuthFetchResponse(clusterDetails); + }, }); + await store.dispatch(fetchClusterShardingData()); + expect(store.getState().status).to.equal('UNSHARDED'); + expect(store.getState().managedNamespace).to.equal(undefined); + + const promise = store.dispatch(createShardKey(shardKeyData)); + expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING'); + await promise; + expect(store.getState().status).to.equal('UNSHARDED'); + }); - it('sets SUBMITTING_FOR_SHARDING state when starting to create shard key and sets to UNSHARDED on failure', async function () { - const store = createStore({ - authenticatedFetch: () => Promise.reject(new Error('error')), - }); + it('when the namespace is managed', async function () { + const store = createStore({ + authenticatedFetch: (uri: string) => { + if (uri.includes('/clusters/')) { + return createAuthFetchResponse({ + ...clusterDetails, + geoSharding: { + ...clusterDetails.geoSharding, + managedNamespaces: [managedNamespace], + }, + }); + } + + if (uri.includes('/deploymentStatus/')) { + return createAuthFetchResponse({ + automationStatus: { + processes: [], + }, + }); + } + + return createAuthFetchResponse({}); + }, + automationAgentRequest: (_meta: unknown, type: string) => ({ + _id: '123', + requestType: type, + }), + automationAgentAwait: (_meta: unknown, type: string) => { + if (type === 'getShardKey') { + return { + response: [ + { + key: { + location: 'HASHED', + secondary: 'HASHED', + }, + unique: false, + }, + ], + }; + } + }, + }); + await store.dispatch(fetchClusterShardingData()); + await waitFor(() => { + expect(store.getState().status).to.equal('SHARD_KEY_CORRECT'); + expect(store.getState().managedNamespace).to.equal(managedNamespace); + }); + }); - const promise = store.dispatch(createShardKey(shardKeyData)); - expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING'); + it('sends correct data to the server when creating a shard key', async function () { + const alreadyManagedNamespaces = [ + { + db: 'test', + collection: 'one', + customShardKey: 'test', + isCustomShardKeyHashed: true, + isShardKeyUnique: false, + numInitialChunks: 1, + presplitHashedZones: true, + }, + ]; + + const getClusterInfoApiResponse = createAuthFetchResponse({ + ...clusterDetails, + geoSharding: { + ...clusterDetails.geoSharding, + managedNamespaces: alreadyManagedNamespaces, + }, + }); - await promise; - expect(store.getState().status).to.equal('UNSHARDED'); + // We call cluster API when store is activated to get the initial state. + // When creating a shard key, we call the same API to fetch the latest list of + // managed namespaces & then send it to the server along with the shard key data. + // So, we mock first and second call with same data. And then third call + // should be to create the shard key. + const fetchStub = sinon + .stub() + .onFirstCall() + .returns(getClusterInfoApiResponse) + .onSecondCall() + .returns(getClusterInfoApiResponse) + .onThirdCall() + .resolves(); + + const store = createStore({ + authenticatedFetch: fetchStub, }); - it('sends correct data to the server when creating a shard key', async function () { - const alreadyManagedNamespaces = [ - { - db: 'test', - collection: 'one', - customShardKey: 'test', - isCustomShardKeyHashed: true, - isShardKeyUnique: false, - numInitialChunks: 1, - presplitHashedZones: true, - }, - ]; - - const getClusterInfoApiResponse = createJsonResponse({ - geoSharding: { - customZoneMapping: {}, - managedNamespaces: alreadyManagedNamespaces, - }, - }); - - // We call cluster API when store is activated to get the initial state. - // When creating a shard key, we call the same API to fetch the latest list of - // managed namespaces & then send it to the server along with the shard key data. - // So, we mock first and second call with same data. And then third call - // should be to create the shard key. - const fetchStub = sinon - .stub() - .onFirstCall() - .returns(getClusterInfoApiResponse) - .onSecondCall() - .returns(getClusterInfoApiResponse) - .onThirdCall() - .resolves(); - - const store = createStore({ - authenticatedFetch: fetchStub, - }); - - await store.dispatch(createShardKey(shardKeyData)); - - const options = fetchStub.getCall(2).args[1]; - expect(options.method).to.equal('PATCH'); - expect(JSON.parse(options.body)).to.deep.equal({ - customZoneMapping: {}, - managedNamespaces: [ - ...alreadyManagedNamespaces, - { ...shardKeyData, db: DB, collection: COLL }, - ], - }); + await store.dispatch(createShardKey(shardKeyData)); + + const options = fetchStub.getCall(2).args[1]; + expect(options.method).to.equal('PATCH'); + expect(JSON.parse(options.body)).to.deep.equal({ + customZoneMapping: {}, + managedNamespaces: [ + ...alreadyManagedNamespaces, + { ...shardKeyData, db: DB, collection: COLL }, + ], + selfManagedSharding: false, }); }); }); diff --git a/packages/compass-global-writes/src/store/index.ts b/packages/compass-global-writes/src/store/index.ts index 334034a52a6..b00fad27e27 100644 --- a/packages/compass-global-writes/src/store/index.ts +++ b/packages/compass-global-writes/src/store/index.ts @@ -56,13 +56,16 @@ export function activateGlobalWritesPlugin( }: GlobalWritesPluginServices, { cleanup }: ActivateHelpers ) { - const atlasGlobalWritesService = new AtlasGlobalWritesService(atlasService); + const atlasGlobalWritesService = new AtlasGlobalWritesService( + atlasService, + connectionInfoRef + ); const store: GlobalWritesStore = createStore( reducer, { namespace: options.namespace, - isNamespaceSharded: false, status: ShardingStatuses.NOT_READY, + shardZones: [], }, applyMiddleware( thunk.withExtraArgument({ diff --git a/packages/compass-global-writes/src/store/reducer.ts b/packages/compass-global-writes/src/store/reducer.ts index 17e1dba65db..85981a039df 100644 --- a/packages/compass-global-writes/src/store/reducer.ts +++ b/packages/compass-global-writes/src/store/reducer.ts @@ -20,15 +20,39 @@ export type CreateShardKeyData = Pick< >; enum GlobalWritesActionTypes { - IsManagedNamespaceFetched = 'global-writes/IsManagedNamespaceFetched', + ManagedNamespaceFetched = 'global-writes/ManagedNamespaceFetched', + NamespaceShardingErrorFetched = 'global-writes/NamespaceShardingErrorFetched', + NamespaceShardKeyFetched = 'global-writes/NamespaceShardKeyFetched', + + ShardZonesFetched = 'global-writes/ShardZonesFetched', + SubmittingForShardingStarted = 'global-writes/SubmittingForShardingStarted', SubmittingForShardingFinished = 'global-writes/SubmittingForShardingFinished', SubmittingForShardingErrored = 'global-writes/SubmittingForShardingErrored', + + UnmanagingNamespaceStarted = 'global-writes/UnmanagingNamespaceStarted', + UnmanagingNamespaceFinished = 'global-writes/UnmanagingNamespaceFinished', + UnmanagingNamespaceErrored = 'global-writes/UnmanagingNamespaceErrored', } -type IsManagedNamespaceFetchedAction = { - type: GlobalWritesActionTypes.IsManagedNamespaceFetched; - isNamespaceManaged: boolean; +type ManagedNamespaceFetchedAction = { + type: GlobalWritesActionTypes.ManagedNamespaceFetched; + managedNamespace?: ManagedNamespace; +}; + +type NamespaceShardingErrorFetchedAction = { + type: GlobalWritesActionTypes.NamespaceShardingErrorFetched; + error: string; +}; + +type NamespaceShardKeyFetchedAction = { + type: GlobalWritesActionTypes.NamespaceShardKeyFetched; + shardKey: ShardKey; +}; + +type ShardZonesFetchedAction = { + type: GlobalWritesActionTypes.ShardZonesFetched; + shardZones: ShardZoneData[]; }; type SubmittingForShardingStartedAction = { @@ -37,12 +61,25 @@ type SubmittingForShardingStartedAction = { type SubmittingForShardingFinishedAction = { type: GlobalWritesActionTypes.SubmittingForShardingFinished; + managedNamespace?: ManagedNamespace; }; type SubmittingForShardingErroredAction = { type: GlobalWritesActionTypes.SubmittingForShardingErrored; }; +type UnmanagingNamespaceStartedAction = { + type: GlobalWritesActionTypes.UnmanagingNamespaceStarted; +}; + +type UnmanagingNamespaceFinishedAction = { + type: GlobalWritesActionTypes.UnmanagingNamespaceFinished; +}; + +type UnmanagingNamespaceErroredAction = { + type: GlobalWritesActionTypes.UnmanagingNamespaceErrored; +}; + export enum ShardingStatuses { /** * Initial status, no information available yet. @@ -64,43 +101,162 @@ export enum ShardingStatuses { * Namespace is being sharded. */ SHARDING = 'SHARDING', + + /** + * Sharding failed. + */ + SHARDING_ERROR = 'SHARDING_ERROR', + + /** + * If the first key is not valid location key or the key is not compound. + */ + SHARD_KEY_INVALID = 'SHARD_KEY_INVALID', + + /** + * If the first key is valid (location key) and second key is not valid. + * The second key valid means that it matches with the managedNamespace's + * customShardKey and is of the correct type. + */ + SHARD_KEY_MISMATCH = 'SHARD_KEY_MISMATCH', + + /** + * Namespace is geo-sharded. Both, first key is valid + * location key and second key is valid custom key. + */ + SHARD_KEY_CORRECT = 'SHARD_KEY_CORRECT', + + /** + * Namespace is being unmanaged. + */ + UNMANAGING_NAMESPACE = 'UNMANAGING_NAMESPACE', } export type ShardingStatus = keyof typeof ShardingStatuses; - +export type ShardKey = { + fields: Array<{ + type: 'HASHED' | 'RANGE'; + name: string; + }>; + isUnique: boolean; +}; +export type ShardZoneData = { + zoneId: string; + country: string; + readableName: string; + isoCode: string; + typeOneIsoCode: string; + zoneName: string; + zoneLocations: string[]; +}; export type RootState = { namespace: string; - isNamespaceSharded: boolean; - status: ShardingStatus; -}; + managedNamespace?: ManagedNamespace; + shardZones: ShardZoneData[]; +} & ( + | { + status: ShardingStatuses.NOT_READY; + shardKey?: never; + shardingError?: never; + } + | { + status: + | ShardingStatuses.UNSHARDED + | ShardingStatuses.SUBMITTING_FOR_SHARDING + | ShardingStatuses.SHARDING; + /** + * note: shardKey might exist even for unsharded. + * if the collection was sharded previously and then unmanaged + */ + shardKey?: ShardKey; + shardingError?: never; + } + | { + status: ShardingStatuses.SHARDING_ERROR; + shardKey?: never; + shardingError: string; + } + | { + status: + | ShardingStatuses.SHARD_KEY_CORRECT + | ShardingStatuses.SHARD_KEY_INVALID + | ShardingStatuses.SHARD_KEY_MISMATCH + | ShardingStatuses.UNMANAGING_NAMESPACE; + shardKey: ShardKey; + shardingError?: never; + } +); const initialState: RootState = { namespace: '', - isNamespaceSharded: false, status: ShardingStatuses.NOT_READY, + shardZones: [], }; const reducer: Reducer = (state = initialState, action) => { if ( - isAction( + isAction( action, - GlobalWritesActionTypes.IsManagedNamespaceFetched - ) + GlobalWritesActionTypes.ManagedNamespaceFetched + ) && + state.status === ShardingStatuses.NOT_READY ) { return { ...state, - isNamespaceSharded: action.isNamespaceManaged, - status: !action.isNamespaceManaged + managedNamespace: action.managedNamespace, + status: !action.managedNamespace ? ShardingStatuses.UNSHARDED : state.status, }; } + if ( + isAction( + action, + GlobalWritesActionTypes.NamespaceShardingErrorFetched + ) && + state.status === ShardingStatuses.NOT_READY + ) { + return { + ...state, + status: ShardingStatuses.SHARDING_ERROR, + shardKey: undefined, + shardingError: action.error, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.NamespaceShardKeyFetched + ) && + state.status === ShardingStatuses.NOT_READY + ) { + return { + ...state, + status: getStatusFromShardKey(action.shardKey, state.managedNamespace), + shardKey: action.shardKey, + shardingError: undefined, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.ShardZonesFetched + ) + ) { + return { + ...state, + shardZones: action.shardZones, + }; + } + if ( isAction( action, GlobalWritesActionTypes.SubmittingForShardingStarted - ) + ) && + state.status === ShardingStatuses.UNSHARDED ) { return { ...state, @@ -112,11 +268,12 @@ const reducer: Reducer = (state = initialState, action) => { isAction( action, GlobalWritesActionTypes.SubmittingForShardingFinished - ) + ) && + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING ) { return { ...state, - isNamespaceSharded: true, + managedNamespace: action.managedNamespace || state.managedNamespace, status: ShardingStatuses.SHARDING, }; } @@ -125,49 +282,86 @@ const reducer: Reducer = (state = initialState, action) => { isAction( action, GlobalWritesActionTypes.SubmittingForShardingErrored - ) + ) && + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING + ) { + return { + ...state, + managedNamespace: undefined, + status: ShardingStatuses.UNSHARDED, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.UnmanagingNamespaceStarted + ) && + (state.status === ShardingStatuses.SHARD_KEY_CORRECT || + state.status === ShardingStatuses.SHARD_KEY_INVALID || + state.status === ShardingStatuses.SHARD_KEY_MISMATCH) ) { return { ...state, + status: ShardingStatuses.UNMANAGING_NAMESPACE, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.UnmanagingNamespaceFinished + ) && + state.status === ShardingStatuses.UNMANAGING_NAMESPACE + ) { + return { + ...state, + managedNamespace: undefined, status: ShardingStatuses.UNSHARDED, }; } + if ( + isAction( + action, + GlobalWritesActionTypes.UnmanagingNamespaceErrored + ) && + state.status === ShardingStatuses.UNMANAGING_NAMESPACE + ) { + return { + ...state, + status: ShardingStatuses.SHARD_KEY_CORRECT, + }; + } + return state; }; export const fetchClusterShardingData = - (): GlobalWritesThunkAction, IsManagedNamespaceFetchedAction> => + (): GlobalWritesThunkAction, ManagedNamespaceFetchedAction> => async ( dispatch, getState, - { atlasGlobalWritesService, connectionInfoRef, logger } + { atlasGlobalWritesService, logger, connectionInfoRef } ) => { - if (!connectionInfoRef.current.atlasMetadata) { - return; - } - const { namespace } = getState(); - const { clusterName, projectId } = connectionInfoRef.current.atlasMetadata; - try { // Call the API to check if the namespace is managed. If the namespace is managed, // we would want to fetch more data that is needed to figure out the state and // accordingly show the UI to the user. - const isNamespaceManaged = - await atlasGlobalWritesService.isNamespaceManaged(namespace, { - projectId, - clusterName, - }); + const managedNamespace = + await atlasGlobalWritesService.getManagedNamespace(namespace); dispatch({ - type: GlobalWritesActionTypes.IsManagedNamespaceFetched, - isNamespaceManaged, + type: GlobalWritesActionTypes.ManagedNamespaceFetched, + managedNamespace, }); - if (!isNamespaceManaged) { + if (!managedNamespace) { return; } - // TODO (COMPASS-8277): Now fetch the sharding key and possible process error. + + // At this point, the namespace is managed and we want to fetch the sharding key. + void dispatch(fetchNamespaceShardKey()); } catch (error) { logger.log.error( logger.mongoLogId(1_001_000_330), @@ -175,50 +369,44 @@ export const fetchClusterShardingData = 'Error fetching cluster sharding data', (error as Error).message ); - openToast('global-writes-fetch-shard-info-error', { - title: `Failed to fetch sharding information: ${ - (error as Error).message - }`, - dismissible: true, - timeout: 5000, - variant: 'important', - }); + openToast( + `global-writes-fetch-shard-info-error-${connectionInfoRef.current.id}-${namespace}`, + { + title: `Failed to fetch sharding information: ${ + (error as Error).message + }`, + dismissible: true, + timeout: 5000, + variant: 'important', + } + ); } }; -export const createShardKey = - ( - data: CreateShardKeyData - ): GlobalWritesThunkAction< - Promise, - | SubmittingForShardingStartedAction - | SubmittingForShardingFinishedAction - | SubmittingForShardingErroredAction - > => - async ( +export const createShardKey = ( + data: CreateShardKeyData +): GlobalWritesThunkAction< + Promise, + | SubmittingForShardingStartedAction + | SubmittingForShardingFinishedAction + | SubmittingForShardingErroredAction +> => { + return async ( dispatch, getState, - { connectionInfoRef, atlasGlobalWritesService, logger } + { atlasGlobalWritesService, logger, connectionInfoRef } ) => { - if (!connectionInfoRef.current.atlasMetadata) { - return; - } - const { namespace } = getState(); - const { clusterName, projectId } = connectionInfoRef.current.atlasMetadata; - dispatch({ type: GlobalWritesActionTypes.SubmittingForShardingStarted, }); try { - await atlasGlobalWritesService.createShardKey(namespace, data, { - projectId, - clusterName, - }); - dispatch({ - type: GlobalWritesActionTypes.SubmittingForShardingFinished, - }); + const managedNamespace = await atlasGlobalWritesService.createShardKey( + namespace, + data + ); + dispatch(setNamespaceBeingSharded(managedNamespace)); } catch (error) { logger.log.error( logger.mongoLogId(1_001_000_331), @@ -229,16 +417,173 @@ export const createShardKey = data, } ); - openToast('global-writes-create-shard-key-error', { - title: `Failed to create shard key: ${(error as Error).message}`, - dismissible: true, - timeout: 5000, - variant: 'important', - }); + openToast( + `global-writes-create-shard-key-error-${connectionInfoRef.current.id}-${namespace}`, + { + title: `Failed to create shard key: ${(error as Error).message}`, + dismissible: true, + timeout: 5000, + variant: 'important', + } + ); dispatch({ type: GlobalWritesActionTypes.SubmittingForShardingErrored, }); } }; +}; + +const setNamespaceBeingSharded = ( + managedNamespace?: ManagedNamespace +): GlobalWritesThunkAction => { + return (dispatch) => { + dispatch({ + type: GlobalWritesActionTypes.SubmittingForShardingFinished, + managedNamespace, + }); + }; +}; + +export const fetchNamespaceShardKey = (): GlobalWritesThunkAction< + Promise, + NamespaceShardingErrorFetchedAction | NamespaceShardKeyFetchedAction +> => { + return async ( + dispatch, + getState, + { atlasGlobalWritesService, logger, connectionInfoRef } + ) => { + const { namespace } = getState(); + + try { + const [shardingError, shardKey] = await Promise.all([ + atlasGlobalWritesService.getShardingError(namespace), + atlasGlobalWritesService.getShardingKeys(namespace), + ]); + + if (shardingError) { + dispatch({ + type: GlobalWritesActionTypes.NamespaceShardingErrorFetched, + error: shardingError, + }); + return; + } + + if (!shardKey) { + dispatch(setNamespaceBeingSharded()); + return; + } + + dispatch({ + type: GlobalWritesActionTypes.NamespaceShardKeyFetched, + shardKey, + }); + void dispatch(fetchShardingZones()); + } catch (error) { + logger.log.error( + logger.mongoLogId(1_001_000_333), + 'AtlasFetchError', + 'Error fetching shard key', + (error as Error).message + ); + openToast( + `global-writes-fetch-shard-key-error-${connectionInfoRef.current.id}-${namespace}`, + { + title: `Failed to fetch shard key: ${(error as Error).message}`, + dismissible: true, + timeout: 5000, + variant: 'important', + } + ); + } + }; +}; + +export const fetchShardingZones = (): GlobalWritesThunkAction< + Promise, + ShardZonesFetchedAction +> => { + return async (dispatch, getState, { atlasGlobalWritesService }) => { + const { shardZones } = getState(); + if (shardZones.length > 0) { + return; + } + const shardingZones = await atlasGlobalWritesService.getShardingZones(); + dispatch({ + type: GlobalWritesActionTypes.ShardZonesFetched, + shardZones: shardingZones, + }); + }; +}; + +export const unmanageNamespace = (): GlobalWritesThunkAction< + Promise, + | UnmanagingNamespaceStartedAction + | UnmanagingNamespaceFinishedAction + | UnmanagingNamespaceErroredAction +> => { + return async ( + dispatch, + getState, + { atlasGlobalWritesService, connectionInfoRef } + ) => { + const { namespace } = getState(); + + dispatch({ + type: GlobalWritesActionTypes.UnmanagingNamespaceStarted, + }); + + try { + await atlasGlobalWritesService.unmanageNamespace(namespace); + dispatch({ + type: GlobalWritesActionTypes.UnmanagingNamespaceFinished, + }); + } catch (error) { + dispatch({ + type: GlobalWritesActionTypes.UnmanagingNamespaceErrored, + }); + openToast( + `global-writes-unmanage-namespace-error-${connectionInfoRef.current.id}-${namespace}`, + { + title: `Failed to unmanage namespace: ${(error as Error).message}`, + dismissible: true, + timeout: 5000, + variant: 'important', + } + ); + } + }; +}; + +export function getStatusFromShardKey( + shardKey: ShardKey, + managedNamespace?: ManagedNamespace +) { + const [firstShardKey, secondShardKey] = shardKey.fields; + + // For a shard key to be valid: + // 1. The first key must be location and of type RANGE. + // 2. The second key name must match managedNamespace.customShardKey and + // the type must match the managedNamespace.isCustomShardKeyHashed. + + const isLocatonKeyValid = + firstShardKey.name === 'location' && firstShardKey.type === 'RANGE'; + const isCustomKeyValid = + managedNamespace && + managedNamespace.isShardKeyUnique === shardKey.isUnique && + secondShardKey.name === managedNamespace.customShardKey && + secondShardKey.type === + (managedNamespace.isCustomShardKeyHashed ? 'HASHED' : 'RANGE'); + + if (!isLocatonKeyValid || !secondShardKey) { + return ShardingStatuses.SHARD_KEY_INVALID; + } + + if (!isCustomKeyValid) { + return ShardingStatuses.SHARD_KEY_MISMATCH; + } + + return ShardingStatuses.SHARD_KEY_CORRECT; +} export default reducer; diff --git a/packages/compass-global-writes/tests/create-store.tsx b/packages/compass-global-writes/tests/create-store.tsx index 0aad67f78dc..631a6d6d5d9 100644 --- a/packages/compass-global-writes/tests/create-store.tsx +++ b/packages/compass-global-writes/tests/create-store.tsx @@ -7,13 +7,25 @@ import { activateGlobalWritesPlugin } from '../src/store'; import { createActivateHelpers } from 'hadron-app-registry'; import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; -import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +import type { ConnectionInfo } from '@mongodb-js/compass-connections/provider'; import type { AtlasService } from '@mongodb-js/atlas-service/provider'; import { Provider } from 'react-redux'; -import { render } from '@mongodb-js/testing-library-compass'; +import { renderWithActiveConnection } from '@mongodb-js/testing-library-compass'; import clusterApiResponse from './cluster-api-response.json'; +const TEST_CONNECTION_INFO = { + id: 'TEST', + connectionOptions: { + connectionString: 'mongodb://localhost', + }, + atlasMetadata: { + clusterName: 'Cluster0', + clusterType: 'UNSHARDED', + projectId: 'Project0', + } as unknown as ConnectionInfo['atlasMetadata'], +}; + const atlasService = { cloudEndpoint: (p: string) => { return `https://example.com/${p}`; @@ -36,19 +48,9 @@ const atlasService = { export const setupStore = ( options: Partial = {}, - services: Partial = {} + services: Partial = {}, + connectionInfo: ConnectionInfo = TEST_CONNECTION_INFO ) => { - const connectionInfoRef = { - current: { - id: 'TEST', - atlasMetadata: { - clusterName: 'Cluster0', - clusterType: 'GEOSHARDED', - projectId: 'Project0', - }, - }, - } as ConnectionInfoRef; - return activateGlobalWritesPlugin( { namespace: 'airbnb.listings', @@ -57,7 +59,12 @@ export const setupStore = ( { logger: createNoopLogger('TEST'), track: createNoopTrack(), - connectionInfoRef, + connectionInfoRef: { + current: { + ...connectionInfo, + title: 'My connection', + }, + }, ...services, atlasService: { ...atlasService, @@ -70,10 +77,19 @@ export const setupStore = ( export const renderWithStore = ( component: JSX.Element, - services: Partial = {}, - options: Partial = {} + { + services = {}, + options = {}, + connectionInfo = TEST_CONNECTION_INFO, + }: { + services?: Partial; + options?: Partial; + connectionInfo?: ConnectionInfo; + } = {} ) => { - const store = setupStore(options, services); - render({component}); - return store; + const store = setupStore(options, services, connectionInfo); + return renderWithActiveConnection( + {component}, + connectionInfo + ); };