diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 1f048df70..a55a2f9d0 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -35,6 +35,7 @@ "A connection with the same username and host already exists.": "A connection with the same username and host already exists.", "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.": "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.", "A value is required to proceed.": "A value is required to proceed.", + "Account information is incomplete.": "Account information is incomplete.", "Add new document": "Add new document", "Advanced": "Advanced", "All available providers have been added already.": "All available providers have been added already.", @@ -56,9 +57,11 @@ "Authentication is required to run this action.": "Authentication is required to run this action.", "Authentication is required to use this migration provider.": "Authentication is required to use this migration provider.", "Azure Activity": "Azure Activity", + "Azure Cosmos DB for MongoDB (RU)": "Azure Cosmos DB for MongoDB (RU)", "Azure Cosmos DB for MongoDB (RU) Emulator": "Azure Cosmos DB for MongoDB (RU) Emulator", "Azure Cosmos DB for MongoDB (vCore)": "Azure Cosmos DB for MongoDB (vCore)", "Azure Service Discovery": "Azure Service Discovery", + "Azure Service Discovery for MongoDB RU": "Azure Service Discovery for MongoDB RU", "Azure VM Service Discovery": "Azure VM Service Discovery", "Azure VM: Attempting to authenticate with \"{vmName}\"…": "Azure VM: Attempting to authenticate with \"{vmName}\"…", "Azure VM: Connected to \"{vmName}\" as \"{username}\".": "Azure VM: Connected to \"{vmName}\" as \"{username}\".", @@ -70,6 +73,7 @@ "Change page size": "Change page size", "Check document syntax": "Check document syntax", "Choose a cluster…": "Choose a cluster…", + "Choose a RU cluster…": "Choose a RU cluster…", "Choose a subscription…": "Choose a subscription…", "Choose a Virtual Machine…": "Choose a Virtual Machine…", "Choose the data migration provider…": "Choose the data migration provider…", @@ -266,10 +270,12 @@ "Load More...": "Load More...", "Loading \"{0}\"...": "Loading \"{0}\"...", "Loading cluster details for \"{cluster}\"": "Loading cluster details for \"{cluster}\"", + "Loading clusters…": "Loading clusters…", "Loading Content": "Loading Content", "Loading document {num} of {countUri}": "Loading document {num} of {countUri}", "Loading documents…": "Loading documents…", "Loading resources...": "Loading resources...", + "Loading RU clusters…": "Loading RU clusters…", "Loading subscriptions…": "Loading subscriptions…", "Loading Virtual Machines…": "Loading Virtual Machines…", "Loading...": "Loading...", @@ -434,6 +440,7 @@ "Unable to connect to the local database instance. Make sure it is started correctly. See {link} for tips.": "Unable to connect to the local database instance. Make sure it is started correctly. See {link} for tips.", "Unable to connect to the local instance. Make sure it is started correctly. See {link} for tips.": "Unable to connect to the local instance. Make sure it is started correctly. See {link} for tips.", "Unable to parse syntax near line {line}, col {column}: {message}": "Unable to parse syntax near line {line}, col {column}: {message}", + "Unable to retrieve credentials for cluster \"{cluster}\".": "Unable to retrieve credentials for cluster \"{cluster}\".", "Unable to retrieve credentials for the selected cluster.": "Unable to retrieve credentials for the selected cluster.", "Unexpected status code: {0}": "Unexpected status code: {0}", "Unknown error": "Unknown error", diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index f5c20cd71..955ddc541 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -47,8 +47,9 @@ import { updateConnectionString } from '../commands/updateConnectionString/updat import { updateCredentials } from '../commands/updateCredentials/updateCredentials'; import { isVCoreAndRURolloutEnabled } from '../extension'; import { ext } from '../extensionVariables'; +import { AzureMongoRUDiscoveryProvider } from '../plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider'; +import { AzureDiscoveryProvider } from '../plugins/service-azure-mongo-vcore/AzureDiscoveryProvider'; import { AzureVMDiscoveryProvider } from '../plugins/service-azure-vm/AzureVMDiscoveryProvider'; -import { AzureDiscoveryProvider } from '../plugins/service-azure/AzureDiscoveryProvider'; import { DiscoveryService } from '../services/discoveryServices'; import { VCoreBranchDataProvider } from '../tree/azure-resources-view/documentdb/VCoreBranchDataProvider'; import { RUBranchDataProvider } from '../tree/azure-resources-view/mongo-ru/RUBranchDataProvider'; @@ -70,6 +71,7 @@ export class ClustersExtension implements vscode.Disposable { registerDiscoveryServices(_activateContext: IActionContext) { DiscoveryService.registerProvider(new AzureDiscoveryProvider()); + DiscoveryService.registerProvider(new AzureMongoRUDiscoveryProvider()); DiscoveryService.registerProvider(new AzureVMDiscoveryProvider()); } diff --git a/src/plugins/api-shared/azure/wizard/AzureContextProperties.ts b/src/plugins/api-shared/azure/wizard/AzureContextProperties.ts new file mode 100644 index 000000000..6ec698d0f --- /dev/null +++ b/src/plugins/api-shared/azure/wizard/AzureContextProperties.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export enum AzureContextProperties { + AzureSubscriptionProvider = 'azureSubscriptionProvider', + SelectedSubscription = 'selectedSubscription', + SelectedCluster = 'selectedCluster', +} diff --git a/src/plugins/service-azure/discovery-wizard/SelectSubscriptionStep.ts b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts similarity index 93% rename from src/plugins/service-azure/discovery-wizard/SelectSubscriptionStep.ts rename to src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts index b970f9ec5..54bbe9a9d 100644 --- a/src/plugins/service-azure/discovery-wizard/SelectSubscriptionStep.ts +++ b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts @@ -7,9 +7,9 @@ import { VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureau import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import { Uri, window, type MessageItem, type QuickPickItem } from 'vscode'; -import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; -import { ext } from '../../../extensionVariables'; -import { AzureContextProperties } from '../AzureDiscoveryProvider'; +import { type NewConnectionWizardContext } from '../../../../commands/newConnection/NewConnectionWizardContext'; +import { ext } from '../../../../extensionVariables'; +import { AzureContextProperties } from './AzureContextProperties'; export class SelectSubscriptionStep extends AzureWizardPromptStep { iconPath = Uri.joinPath( diff --git a/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts new file mode 100644 index 000000000..34211b5bd --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext, type IWizardOptions } from '@microsoft/vscode-azext-utils'; +import { Disposable, l10n, ThemeIcon } from 'vscode'; +import { type NewConnectionWizardContext } from '../../commands/newConnection/NewConnectionWizardContext'; +import { ext } from '../../extensionVariables'; +import { type DiscoveryProvider } from '../../services/discoveryServices'; +import { type TreeElement } from '../../tree/TreeElement'; +import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; +import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering'; +import { AzureContextProperties } from '../api-shared/azure/wizard/AzureContextProperties'; +import { SelectSubscriptionStep } from '../service-azure-vm/discovery-wizard/SelectSubscriptionStep'; +import { AzureMongoRUServiceRootItem } from './discovery-tree/AzureMongoRUServiceRootItem'; +import { AzureMongoRUExecuteStep } from './discovery-wizard/AzureMongoRUExecuteStep'; +import { SelectRUClusterStep } from './discovery-wizard/SelectRUClusterStep'; + +export class AzureMongoRUDiscoveryProvider extends Disposable implements DiscoveryProvider { + id = 'azure-mongo-ru-discovery'; + label = l10n.t('Azure Cosmos DB for MongoDB (RU)'); + description = l10n.t('Azure Service Discovery for MongoDB RU'); + iconPath = new ThemeIcon('azure'); + + azureSubscriptionProvider: AzureSubscriptionProviderWithFilters; + + constructor() { + super(() => { + this.azureSubscriptionProvider.dispose(); + }); + + this.azureSubscriptionProvider = new AzureSubscriptionProviderWithFilters(); + } + + getDiscoveryTreeRootItem(parentId: string): TreeElement { + return new AzureMongoRUServiceRootItem(this.azureSubscriptionProvider, parentId); + } + + getDiscoveryWizard(context: NewConnectionWizardContext): IWizardOptions { + context.properties[AzureContextProperties.AzureSubscriptionProvider] = this.azureSubscriptionProvider; + + return { + title: l10n.t('Azure Service Discovery'), + promptSteps: [new SelectSubscriptionStep(), new SelectRUClusterStep()], + executeSteps: [new AzureMongoRUExecuteStep()], + showLoadingPrompt: true, + }; + } + + getLearnMoreUrl(): string | undefined { + return 'https://aka.ms/vscode-documentdb-discovery-providers-azure-ru'; + } + + async configureTreeItemFilter(context: IActionContext, node: TreeElement): Promise { + if (node instanceof AzureMongoRUServiceRootItem) { + await configureAzureSubscriptionFilter(context, this.azureSubscriptionProvider); + ext.discoveryBranchDataProvider.refresh(node); + } + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts new file mode 100644 index 000000000..5a60b20cf --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ext } from '../../../extensionVariables'; +import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; +import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; +import { + isTreeElementWithContextValue, + type TreeElementWithContextValue, +} from '../../../tree/TreeElementWithContextValue'; +import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { AzureMongoRUSubscriptionItem } from './AzureMongoRUSubscriptionItem'; + +export class AzureMongoRUServiceRootItem + implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren +{ + public readonly id: string; + public contextValue: string = 'enableRefreshCommand;enableFilterCommand;enableLearnMoreCommand;azureMongoRUService'; + + constructor( + private readonly azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, + public readonly parentId: string, + ) { + this.id = `${parentId}/azure-mongo-ru-discovery`; + } + + async getChildren(): Promise { + /** + * This is an important step to ensure that the user is signed in to Azure before listing subscriptions. + */ + if (!(await this.azureSubscriptionProvider.isSignedIn())) { + const signIn: vscode.MessageItem = { title: l10n.t('Sign In') }; + void vscode.window + .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) + .then(async (input) => { + if (input === signIn) { + await this.azureSubscriptionProvider.signIn(); + ext.discoveryBranchDataProvider.refresh(); + } + }); + + return [ + createGenericElementWithContext({ + contextValue: 'error', // note: keep this in sync with the `hasRetryNode` function in this file + id: `${this.id}/retry`, + label: vscode.l10n.t('Click here to retry'), + iconPath: new vscode.ThemeIcon('refresh'), + commandId: 'vscode-documentdb.command.internal.retry', + commandArgs: [this], + }), + ]; + } + + const subscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); + if (!subscriptions || subscriptions.length === 0) { + return []; + } + + return ( + subscriptions + // sort by name + .sort((a, b) => a.name.localeCompare(b.name)) + // map to AzureMongoRUSubscriptionItem + .map((sub) => { + return new AzureMongoRUSubscriptionItem(this.id, { + subscription: sub, + subscriptionName: sub.name, + subscriptionId: sub.subscriptionId, + }); + }) + ); + } + + public hasRetryNode(children: TreeElement[] | null | undefined): boolean { + return ( + children?.some((child) => isTreeElementWithContextValue(child) && child.contextValue === 'error') ?? false + ); + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: l10n.t('Azure Cosmos DB for MongoDB (RU)'), + iconPath: new vscode.ThemeIcon('azure'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts new file mode 100644 index 000000000..a6cdd22db --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as vscode from 'vscode'; +import { CosmosDBMongoRUExperience } from '../../../DocumentDBExperiences'; +import { ext } from '../../../extensionVariables'; +import { type TreeElement } from '../../../tree/TreeElement'; +import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithContextValue'; +import { type ClusterModel } from '../../../tree/documentdb/ClusterModel'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; +import { nonNullProp } from '../../../utils/nonNull'; +import { MongoRUResourceItem } from './documentdb/MongoRUResourceItem'; + +export interface AzureSubscriptionModel { + subscriptionName: string; + subscription: AzureSubscription; + subscriptionId: string; +} + +export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWithContextValue { + public readonly id: string; + public contextValue: string = 'enableRefreshCommand;azureMongoRUSubscription'; + + constructor( + public readonly parentId: string, + public readonly subscription: AzureSubscriptionModel, + ) { + this.id = `${parentId}/${subscription.subscriptionId}`; + } + + async getChildren(): Promise { + return await callWithTelemetryAndErrorHandling( + 'azure-mongo-ru-discovery.getChildren', + async (context: IActionContext) => { + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + const managementClient = await createCosmosDBManagementClient(context, this.subscription.subscription); + const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); + const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + + return accounts + .sort((a, b) => (a.name || '').localeCompare(b.name || '')) + .map((account) => { + const resourceId = nonNullProp(account, 'id', 'account.id', 'AzureMongoRUSubscriptionItem.ts'); + + const clusterInfo: ClusterModel = { + ...account, + resourceGroup: getResourceGroupFromId(resourceId), + dbExperience: CosmosDBMongoRUExperience, + } as ClusterModel; + + return new MongoRUResourceItem(this.subscription.subscription, clusterInfo); + }); + }, + ); + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: this.subscription.subscriptionName, + tooltip: `Subscription ID: ${this.subscription.subscriptionId}`, + iconPath: vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureSubscription.svg', + ), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts new file mode 100644 index 000000000..828a7047a --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ClustersClient } from '../../../../documentdb/ClustersClient'; +import { CredentialCache } from '../../../../documentdb/CredentialCache'; +import { Views } from '../../../../documentdb/Views'; +import { ext } from '../../../../extensionVariables'; +import { ClusterItemBase, type ClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; +import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; +import { extractCredentialsFromRUAccount } from '../../utils/ruClusterHelpers'; + +export class MongoRUResourceItem extends ClusterItemBase { + iconPath = vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureIcons', + 'MongoClusters.svg', + ); + + constructor( + readonly subscription: AzureSubscription, + cluster: ClusterModel, + ) { + super(cluster); + } + + public async getCredentials(): Promise { + return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + const credentials = await extractCredentialsFromRUAccount( + context, + this.subscription, + this.cluster.resourceGroup!, + this.cluster.name, + ); + + return credentials; + }); + } + + /** + * Authenticates and connects to the MongoDB cluster. + * @returns An instance of ClustersClient if successful; otherwise, null. + */ + protected async authenticateAndConnect(): Promise { + const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + ext.outputChannel.appendLine( + l10n.t('Attempting to authenticate with "{cluster}"…', { + cluster: this.cluster.name, + }), + ); + + try { + // Get credentials for this cluster + const credentials = await this.getCredentials(); + if (!credentials) { + throw new Error( + l10n.t('Unable to retrieve credentials for cluster "{cluster}".', { + cluster: this.cluster.name, + }), + ); + } + + // Cache the credentials for this cluster + CredentialCache.setAuthCredentials( + this.id, + credentials.selectedAuthMethod ?? credentials.availableAuthMethods[0], + credentials.connectionString, + credentials.connectionUser, + credentials.connectionPassword, + ); + + // Connect using the cached credentials + const clustersClient = await ClustersClient.getClient(this.id); + + ext.outputChannel.appendLine( + l10n.t('Connected to the cluster "{cluster}".', { + cluster: this.cluster.name, + }), + ); + + return clustersClient; + } catch (error) { + ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: (error as Error).message })); + + void vscode.window.showErrorMessage( + l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), + { + modal: true, + detail: + l10n.t('Revisit connection details and try again.') + + '\n\n' + + l10n.t('Error: {error}', { error: (error as Error).message }), + }, + ); + + // Clean up failed connection + await ClustersClient.deleteClient(this.id); + CredentialCache.deleteCredentials(this.id); + + return null; + } + }); + + return result ?? null; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts new file mode 100644 index 000000000..63c654ec6 --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; + +import { type GenericResource } from '@azure/arm-resources'; +import { type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; +import { extractCredentialsFromRUAccount } from '../utils/ruClusterHelpers'; + +export class AzureMongoRUExecuteStep extends AzureWizardExecuteStep { + public priority: number = -1; + + public async execute(context: NewConnectionWizardContext): Promise { + if (context.properties[AzureContextProperties.SelectedSubscription] === undefined) { + throw new Error('SelectedSubscription is not set.'); + } + if (context.properties[AzureContextProperties.SelectedCluster] === undefined) { + throw new Error('SelectedCluster is not set.'); + } + + context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + + const subscription = context.properties[ + AzureContextProperties.SelectedSubscription + ] as unknown as AzureSubscription; + + const cluster = context.properties[AzureContextProperties.SelectedCluster] as unknown as GenericResource; + + const resourceGroup = getResourceGroupFromId(cluster.id!); + + const credentials = await extractCredentialsFromRUAccount(context, subscription, resourceGroup, cluster.name!); + + context.connectionString = credentials.connectionString; + context.username = credentials.connectionUser; + context.password = credentials.connectionPassword; + context.availableAuthenticationMethods = credentials.availableAuthMethods; + + // clean-up + context.properties[AzureContextProperties.SelectedSubscription] = undefined; + context.properties[AzureContextProperties.SelectedCluster] = undefined; + context.properties[AzureContextProperties.AzureSubscriptionProvider] = undefined; + } + + public shouldExecute(): boolean { + return true; + } +} diff --git a/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts b/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts new file mode 100644 index 000000000..f161c23cb --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/discovery-wizard/SelectRUClusterStep.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { uiUtils } from '@microsoft/vscode-azext-azureutils'; +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import { Uri, type QuickPickItem } from 'vscode'; +import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; +import { ext } from '../../../extensionVariables'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; + +export class SelectRUClusterStep extends AzureWizardPromptStep { + iconPath = Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureIcons', + 'MongoClusters.svg', + ); + + public async prompt(context: NewConnectionWizardContext): Promise { + if (context.properties[AzureContextProperties.SelectedSubscription] === undefined) { + throw new Error('SelectedSubscription is not set.'); + } + + const managementClient = await createCosmosDBManagementClient( + context, + context.properties[AzureContextProperties.SelectedSubscription] as unknown as AzureSubscription, + ); + + const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); + const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + + const promptItems: (QuickPickItem & { id: string })[] = accounts + .filter((account) => account.name) // Filter out accounts without a name + .map((account) => ({ + id: account.id!, + label: account.name!, + description: account.id, + iconPath: this.iconPath, + + alwaysShow: true, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selectedItem = await context.ui.showQuickPick([...promptItems], { + stepName: 'selectRUCluster', + placeHolder: l10n.t('Choose a RU cluster…'), + loadingPlaceHolder: l10n.t('Loading RU clusters…'), + enableGrouping: true, + matchOnDescription: true, + suppressPersistence: true, + }); + + context.properties[AzureContextProperties.SelectedCluster] = accounts.find( + (account) => account.id === selectedItem.id, + ); + } + + public shouldPrompt(): boolean { + return true; + } +} diff --git a/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts b/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts new file mode 100644 index 000000000..695f0d22d --- /dev/null +++ b/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; +import { maskSensitiveValuesInTelemetry } from '../../../documentdb/utils/connectionStringHelpers'; +import { DocumentDBConnectionString } from '../../../documentdb/utils/DocumentDBConnectionString'; +import { type ClusterCredentials } from '../../../tree/documentdb/ClusterItemBase'; +import { createCosmosDBManagementClient } from '../../../utils/azureClients'; + +/** + * Retrieves cluster information from Azure for RU accounts. + */ +export async function extractCredentialsFromRUAccount( + context: IActionContext, + subscription: AzureSubscription, + resourceGroup: string, + accountName: string, +): Promise { + if (!resourceGroup || !accountName) { + throw new Error(l10n.t('Account information is incomplete.')); + } + + // subscription comes from different azure packages in callers; cast here intentionally + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + const managementClient = await createCosmosDBManagementClient(context, subscription as any); + + const connectionStringsList = await managementClient.databaseAccounts.listConnectionStrings( + resourceGroup, + accountName, + ); + + /** + * databaseAccounts.listConnectionStrings returns an array of (typically 4) connection string objects: + * + * interface DatabaseAccountConnectionString { + * readonly connectionString?: string; + * readonly description?: string; + * readonly keyKind?: Kind; + * readonly type?: Type; + * } + * + * Today we're interested in the one where "keyKind" is "Primary", but this might change in the future. + * Other known values: + * - Primary + * - Secondary + * - PrimaryReadonly + * - SecondaryReadonly + */ + + // More efficient approach + const primaryConnectionString = connectionStringsList?.connectionStrings?.find( + (cs) => cs.keyKind?.toLowerCase() === 'primary', + )?.connectionString; + + // Validate connection string's presence + if (!primaryConnectionString) { + context.telemetry.properties.error = 'missing-connection-string'; + throw new Error( + l10n.t('Authentication data (primary connection string) is missing for "{cluster}".', { + cluster: accountName, + }), + ); + } + + context.valuesToMask.push(primaryConnectionString); + + const parsedCS = new DocumentDBConnectionString(primaryConnectionString); + maskSensitiveValuesInTelemetry(context, parsedCS); + + const username = parsedCS.username; + const password = parsedCS.password; + // do not keep secrets in the connection string + parsedCS.username = ''; + parsedCS.password = ''; + + // the connection string received sometimes contains an 'appName' entry + // with a value that's not escaped, let's just remove it as we don't use + // it here anyway. + parsedCS.searchParams.delete('appName'); + + const clusterCredentials: ClusterCredentials = { + connectionString: parsedCS.toString(), + connectionUser: username, + connectionPassword: password, + availableAuthMethods: [AuthMethodId.NativeAuth], + selectedAuthMethod: AuthMethodId.NativeAuth, + }; + + return clusterCredentials; +} diff --git a/src/plugins/service-azure/AzureDiscoveryProvider.ts b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts similarity index 90% rename from src/plugins/service-azure/AzureDiscoveryProvider.ts rename to src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts index 73a3b8490..a58633d62 100644 --- a/src/plugins/service-azure/AzureDiscoveryProvider.ts +++ b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts @@ -11,19 +11,14 @@ import { type DiscoveryProvider } from '../../services/discoveryServices'; import { type TreeElement } from '../../tree/TreeElement'; import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering'; +import { AzureContextProperties } from '../api-shared/azure/wizard/AzureContextProperties'; +import { SelectSubscriptionStep } from '../service-azure-vm/discovery-wizard/SelectSubscriptionStep'; import { AzureServiceRootItem } from './discovery-tree/AzureServiceRootItem'; import { AzureExecuteStep } from './discovery-wizard/AzureExecuteStep'; import { SelectClusterStep } from './discovery-wizard/SelectClusterStep'; -import { SelectSubscriptionStep } from './discovery-wizard/SelectSubscriptionStep'; - -export enum AzureContextProperties { - AzureSubscriptionProvider = 'azureSubscriptionProvider', - SelectedSubscription = 'selectedSubscription', - SelectedCluster = 'selectedCluster', -} export class AzureDiscoveryProvider extends Disposable implements DiscoveryProvider { - id = 'azure-discovery'; + id = 'azure-mongo-vcore-discovery'; label = l10n.t('Azure Cosmos DB for MongoDB (vCore)'); description = l10n.t('Azure Service Discovery'); iconPath = new ThemeIcon('azure'); diff --git a/src/plugins/service-azure/discovery-tree/AzureServiceRootItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts similarity index 98% rename from src/plugins/service-azure/discovery-tree/AzureServiceRootItem.ts rename to src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts index 223bc79f5..c150280cd 100644 --- a/src/plugins/service-azure/discovery-tree/AzureServiceRootItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts @@ -25,7 +25,7 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext private readonly azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, public readonly parentId: string, ) { - this.id = `${parentId}/azure-discovery`; + this.id = `${parentId}/azure-mongo-vcore-discovery`; } async getChildren(): Promise { diff --git a/src/plugins/service-azure/discovery-tree/AzureSubscriptionItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts similarity index 100% rename from src/plugins/service-azure/discovery-tree/AzureSubscriptionItem.ts rename to src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts diff --git a/src/plugins/service-azure/discovery-tree/documentdb/DocumentDBResourceItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts similarity index 99% rename from src/plugins/service-azure/discovery-tree/documentdb/DocumentDBResourceItem.ts rename to src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts index 3346fafd0..8f33604b5 100644 --- a/src/plugins/service-azure/discovery-tree/documentdb/DocumentDBResourceItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts @@ -49,7 +49,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-discovery'; + context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; // Retrieve and validate cluster information (throws if invalid) const clusterInformation = await getClusterInformationFromAzure( @@ -83,7 +83,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { protected async authenticateAndConnect(): Promise { const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-discovery'; + context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; ext.outputChannel.appendLine( l10n.t('Attempting to authenticate with "{cluster}"…', { @@ -189,7 +189,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { // Prompt the user for credentials await callWithTelemetryAndErrorHandling('connect.promptForCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; - context.telemetry.properties.discoveryProvider = 'azure-discovery'; + context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; context.errorHandling.rethrow = true; context.errorHandling.suppressDisplay = false; diff --git a/src/plugins/service-azure/discovery-wizard/AzureExecuteStep.ts b/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts similarity index 96% rename from src/plugins/service-azure/discovery-wizard/AzureExecuteStep.ts rename to src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts index 484a89f27..798df5ca4 100644 --- a/src/plugins/service-azure/discovery-wizard/AzureExecuteStep.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts @@ -9,7 +9,7 @@ import { type NewConnectionWizardContext } from '../../../commands/newConnection import { type GenericResource } from '@azure/arm-resources'; import { type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; -import { AzureContextProperties } from '../AzureDiscoveryProvider'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; import { extractCredentialsFromCluster, getClusterInformationFromAzure } from '../utils/clusterHelpers'; export class AzureExecuteStep extends AzureWizardExecuteStep { diff --git a/src/plugins/service-azure/discovery-wizard/SelectClusterStep.ts b/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts similarity index 92% rename from src/plugins/service-azure/discovery-wizard/SelectClusterStep.ts rename to src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts index 9dd3e95e1..bc0d0b736 100644 --- a/src/plugins/service-azure/discovery-wizard/SelectClusterStep.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-wizard/SelectClusterStep.ts @@ -11,7 +11,7 @@ import { Uri, type QuickPickItem } from 'vscode'; import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; import { ext } from '../../../extensionVariables'; import { createResourceManagementClient } from '../../../utils/azureClients'; -import { AzureContextProperties } from '../AzureDiscoveryProvider'; +import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; export class SelectClusterStep extends AzureWizardPromptStep { iconPath = Uri.joinPath( @@ -43,6 +43,7 @@ export class SelectClusterStep extends AzureWizardPromptStep account.name) // Filter out accounts without a name .map((account) => ({ id: account.id!, label: account.name!, @@ -56,7 +57,7 @@ export class SelectClusterStep extends AzureWizardPromptStep { iconPath = Uri.joinPath( diff --git a/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts index 856fef9a3..6e61c6117 100644 --- a/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts +++ b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts @@ -25,7 +25,7 @@ import { ext } from '../../../extensionVariables'; import { extractCredentialsFromCluster, getClusterInformationFromAzure, -} from '../../../plugins/service-azure/utils/clusterHelpers'; +} from '../../../plugins/service-azure-mongo-vcore/utils/clusterHelpers'; import { nonNullValue } from '../../../utils/nonNull'; import { ClusterItemBase, type ClusterCredentials } from '../../documentdb/ClusterItemBase'; import { type ClusterModel } from '../../documentdb/ClusterModel'; diff --git a/src/tree/discovery-view/DiscoveryBranchDataProvider.ts b/src/tree/discovery-view/DiscoveryBranchDataProvider.ts index 631f9cf98..f4f1bc9cf 100644 --- a/src/tree/discovery-view/DiscoveryBranchDataProvider.ts +++ b/src/tree/discovery-view/DiscoveryBranchDataProvider.ts @@ -86,7 +86,7 @@ export class DiscoveryBranchDataProvider extends BaseExtendedTreeDataProvider { // Reset the set of root items this.currentRootItems = new WeakSet(); @@ -94,6 +94,9 @@ export class DiscoveryBranchDataProvider extends BaseExtendedTreeDataProvider('activeDiscoveryProviderIds', []); @@ -222,4 +225,71 @@ export class DiscoveryBranchDataProvider extends BaseExtendedTreeDataProvider { + const promotionFlagKey = `discoveryProviderPromotionProcessed:${providerId}`; + const promotionAlreadyShown = ext.context.globalState.get(promotionFlagKey, false); + + if (promotionAlreadyShown) { + // Already shown/processed previously — do nothing. + return; + } + + // If there are no registered discovery providers at all, mark the promotion as shown + // and return early. The goal is to only show the promotion to users who have some + // discovery providers active/installed. + const registeredProviders = DiscoveryService.listProviders(); + if (!registeredProviders || registeredProviders.length === 0) { + try { + await ext.context.globalState.update(promotionFlagKey, true); + } catch { + // ignore storage errors for this best-effort write + } + return; + } + + // Only proceed if the provider is actually available + const provider = DiscoveryService.getProvider(providerId); + if (!provider) { + // Provider not registered with DiscoveryService; skip for now. + return; + } + + // Read current active provider IDs + const activeProviderIds = ext.context.globalState.get('activeDiscoveryProviderIds', []); + + // If not present, register it + if (!activeProviderIds.includes(providerId)) { + const updated = [...activeProviderIds, providerId]; + try { + await ext.context.globalState.update('activeDiscoveryProviderIds', updated); + } catch (error) { + console.error(`Failed to update activeDiscoveryProviderIds: ${(error as Error).message}`); + } + } + + // Mark that we've added/shown the promotion for this provider so we don't repeat it + try { + await ext.context.globalState.update(promotionFlagKey, true); + } catch { + // ignore + } + } + + private async renameLegacyProviders(): Promise { + try { + const activeProviderIds = ext.context.globalState.get('activeDiscoveryProviderIds', []); + if (activeProviderIds.includes('azure-discovery')) { + { + const updated = ext.context.globalState + .get('activeDiscoveryProviderIds', []) + .filter((id) => id !== 'azure-discovery'); + updated.push('azure-mongo-vcore-discovery'); + await ext.context.globalState.update('activeDiscoveryProviderIds', updated); + } + } + } catch { + // ignore storage errors for this best-effort write + } + } }