diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d58ebe85..d4e67e782 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,13 +101,13 @@ jobs: - name: "Az CLI login" uses: azure/login@v1 with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.PREVIEW_SUBSCRIPTION_ID }} + client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} + tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} + subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} - name: Upload build to preview blob storage - run: az storage blob upload-batch -d '$web' -s 'dist' --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} --destination-path "${{github.event.pull_request.head.sha || github.sha}}" --auth-mode login --overwrite true + run: az storage blob upload-batch -d '$web' -s 'dist' --account-name ${{ secrets.E2ETESTS_STORAGEACCOUNT_NAME }} --destination-path "${{github.event.pull_request.head.sha || github.sha}}" --auth-mode login --overwrite true - name: Upload preview config to blob storage - run: az storage blob upload -c '$web' -f ./preview/config.json --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} --name "${{github.event.pull_request.head.sha || github.sha}}/config.json" --auth-mode login --overwrite true + run: az storage blob upload -c '$web' -f ./preview/config.json --account-name ${{ secrets.E2ETESTS_STORAGEACCOUNT_NAME }} --name "${{github.event.pull_request.head.sha || github.sha}}/config.json" --auth-mode login --overwrite true nuget: name: Publish Nuget if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/') @@ -162,7 +162,10 @@ jobs: runs-on: ubuntu-latest env: NODE_TLS_REJECT_UNAUTHORIZED: 0 - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.E2ETESTS_TENANT_ID }} + DE_ACCOUNT_PREFIX: ${{ secrets.E2ETESTS_ACCOUNT_PREFIX }} + DE_TEST_RESOURCE_GROUP: ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} strategy: fail-fast: false matrix: @@ -179,120 +182,47 @@ jobs: - name: "Az CLI login" uses: Azure/login@v2 with: - client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} + tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} + subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} # We can't use MSAL within playwright so we acquire tokens prior to running the tests - name: "Acquire RBAC tokens for test accounts" uses: azure/cli@v2 + env: + DE_ACCOUNT_PREFIX: ${{ secrets.E2ETESTS_ACCOUNT_PREFIX }} + DE_TEST_RESOURCE_GROUP: ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} with: azcliversion: latest inlineScript: | SHARD_INDEX=${{ matrix.shardIndex }} echo PLAYWRIGHT_SHARD_INDEX=$SHARD_INDEX >> $GITHUB_ENV - NOSQL_TESTACCOUNT_1_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-1.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_1_TOKEN" - echo NOSQL_TESTACCOUNT_1_TOKEN=$NOSQL_TESTACCOUNT_1_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_2_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-2.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_2_TOKEN" - echo NOSQL_TESTACCOUNT_2_TOKEN=$NOSQL_TESTACCOUNT_2_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_3_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-3.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_3_TOKEN" - echo NOSQL_TESTACCOUNT_3_TOKEN=$NOSQL_TESTACCOUNT_3_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_4_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-4.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_4_TOKEN" - echo NOSQL_TESTACCOUNT_4_TOKEN=$NOSQL_TESTACCOUNT_4_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_5_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-5.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_5_TOKEN" - echo NOSQL_TESTACCOUNT_5_TOKEN=$NOSQL_TESTACCOUNT_5_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_6_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-6.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_6_TOKEN" - echo NOSQL_TESTACCOUNT_6_TOKEN=$NOSQL_TESTACCOUNT_6_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_7_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-7.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_7_TOKEN" - echo NOSQL_TESTACCOUNT_7_TOKEN=$NOSQL_TESTACCOUNT_7_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_8_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-8.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_8_TOKEN" - echo NOSQL_TESTACCOUNT_8_TOKEN=$NOSQL_TESTACCOUNT_8_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_9_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-9.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_9_TOKEN" - echo NOSQL_TESTACCOUNT_9_TOKEN=$NOSQL_TESTACCOUNT_9_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_10_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-10.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_10_TOKEN" - echo NOSQL_TESTACCOUNT_10_TOKEN=$NOSQL_TESTACCOUNT_10_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_11_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-11.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_11_TOKEN" - echo NOSQL_TESTACCOUNT_11_TOKEN=$NOSQL_TESTACCOUNT_11_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_12_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-12.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_12_TOKEN" - echo NOSQL_TESTACCOUNT_12_TOKEN=$NOSQL_TESTACCOUNT_12_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_13_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-13.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_13_TOKEN" - echo NOSQL_TESTACCOUNT_13_TOKEN=$NOSQL_TESTACCOUNT_13_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_14_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-14.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_14_TOKEN" - echo NOSQL_TESTACCOUNT_14_TOKEN=$NOSQL_TESTACCOUNT_14_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_15_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-15.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_15_TOKEN" - echo NOSQL_TESTACCOUNT_15_TOKEN=$NOSQL_TESTACCOUNT_15_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_16_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-16.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_16_TOKEN" - echo NOSQL_TESTACCOUNT_16_TOKEN=$NOSQL_TESTACCOUNT_16_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_17_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-17.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_17_TOKEN" - echo NOSQL_TESTACCOUNT_17_TOKEN=$NOSQL_TESTACCOUNT_17_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_18_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-18.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_18_TOKEN" - echo NOSQL_TESTACCOUNT_18_TOKEN=$NOSQL_TESTACCOUNT_18_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_19_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-19.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_19_TOKEN" - echo NOSQL_TESTACCOUNT_19_TOKEN=$NOSQL_TESTACCOUNT_19_TOKEN >> $GITHUB_ENV - - NOSQL_TESTACCOUNT_20_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-20.documents.azure.com/.default" -o tsv --query accessToken) - echo "::add-mask::$NOSQL_TESTACCOUNT_20_TOKEN" - echo NOSQL_TESTACCOUNT_20_TOKEN=$NOSQL_TESTACCOUNT_20_TOKEN >> $GITHUB_ENV - - NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken) + NOSQL_TESTACCOUNT=${DE_ACCOUNT_PREFIX}-de-test-sql-${SHARD_INDEX}.documents.azure.com + NOSQL_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://$NOSQL_TESTACCOUNT/.default" -o tsv --query accessToken) + echo "::add-mask::$NOSQL_TESTACCOUNT_TOKEN" + echo NOSQL_TESTACCOUNT_TOKEN=$NOSQL_TESTACCOUNT_TOKEN >> $GITHUB_ENV + NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://$DE_ACCOUNT_PREFIX-de-test-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN" echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV - NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-containercopyonly.documents.azure.com/.default" -o tsv --query accessToken) + NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://${DE_ACCOUNT_PREFIX}-de-test-sql-containercopy.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN" echo NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN >> $GITHUB_ENV - TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken) + TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://${DE_ACCOUNT_PREFIX}-de-test-table-1.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN" echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV - GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken) + GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://${DE_ACCOUNT_PREFIX}-de-test-gremlin-1.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN" echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV - CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken) + CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://${DE_ACCOUNT_PREFIX}-de-test-cassandra-1.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN" echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV - MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken) + MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://${DE_ACCOUNT_PREFIX}-de-test-mongo-1.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN" echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV - MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken) + MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://${DE_ACCOUNT_PREFIX}-de-test-mongo32-1.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN" echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV - MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) + MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://${DE_ACCOUNT_PREFIX}-de-test-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN" echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV - name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} @@ -303,9 +233,9 @@ jobs: if: ${{ !cancelled() }} uses: Azure/login@v2 with: - client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} + tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} + subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} - name: Upload shard blob-report to Azure Storage if: ${{ !cancelled() }} env: @@ -313,7 +243,7 @@ jobs: SHARD: ${{ matrix.shardIndex }} run: | az storage blob upload-batch \ - --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} \ + --account-name ${{ secrets.E2ETESTS_STORAGEACCOUNT_NAME }} \ --auth-mode login \ -d playwright-reports \ -s blob-report \ @@ -342,9 +272,9 @@ jobs: - name: "Az CLI login" uses: Azure/login@v2 with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.PREVIEW_SUBSCRIPTION_ID }} + client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} + tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} + subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} - name: Download all shard reports from Azure Storage env: @@ -352,7 +282,7 @@ jobs: run: | mkdir -p all-blob-reports az storage blob download-batch \ - --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} \ + --account-name ${{ secrets.E2ETESTS_STORAGEACCOUNT_NAME }} \ --auth-mode login \ -s playwright-reports \ --pattern "${KEY}/shards/*" \ @@ -375,7 +305,7 @@ jobs: KEY: ${{ github.run_id }}-${{ github.run_attempt }} run: | az storage blob upload \ - --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} \ + --account-name ${{ secrets.E2ETESTS_STORAGEACCOUNT_NAME }} \ --auth-mode login \ -c playwright-reports \ -n "${KEY}/report.zip" \ @@ -388,7 +318,7 @@ jobs: KEY: ${{ github.run_id }}-${{ github.run_attempt }} run: | az storage blob delete-batch \ - --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} \ + --account-name ${{ secrets.E2ETESTS_STORAGEACCOUNT_NAME }} \ --auth-mode login \ -s playwright-reports \ --pattern "${KEY}/shards/*" @@ -398,7 +328,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} - SA_ID: /subscriptions/${{ secrets.PREVIEW_SUBSCRIPTION_ID }}/resourceGroups/dataexplorer-preview/providers/Microsoft.Storage/storageAccounts/dataexplorerpreview + SA_ID: /subscriptions/${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}/resourceGroups/${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }}/providers/Microsoft.Storage/storageAccounts/${{ secrets.E2ETESTS_STORAGEACCOUNT_NAME }} KEY: ${{ github.run_id }}-${{ github.run_attempt }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 7dfa8a996..b045f3bd3 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -20,16 +20,16 @@ jobs: name: "Cleanup Test Database Accounts" runs-on: ubuntu-latest env: - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} + E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} steps: - uses: actions/checkout@v4 - - name: "Az CLI login" uses: azure/login@v2 with: - client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} + tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} + subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} - name: Use Node.js 20.x uses: actions/setup-node@v4 diff --git a/preview/index.js b/preview/index.js index ccc762ccc..be44c63ed 100644 --- a/preview/index.js +++ b/preview/index.js @@ -5,7 +5,7 @@ const fetch = require("node-fetch"); const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com"; const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net"; -const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/"; +const previewStorageWebsiteEndpoint = "_REPLACE_STORAGE_WEBSITE_ENDPOINT_"; const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer"; const azurePortalMpacEndpoint = "https://ms.portal.azure.com/"; diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 2df718293..dcb19f1a8 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -406,6 +406,7 @@ export enum TerminalKind { Cassandra = 2, Postgres = 3, VCoreMongo = 4, + CosmosDB = 5, } export interface DataExplorerInputsFrame { diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 4aca3ce6b..558beceec 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -179,7 +179,8 @@ export class SettingsComponent extends React.Component, diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx index f3f964060..138b3137d 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx @@ -27,7 +27,6 @@ export interface ContainerPolicyComponentProps { isFullTextSearchEnabled: boolean; shouldDiscardContainerPolicies: boolean; resetShouldDiscardContainerPolicyChange: () => void; - isGlobalSecondaryIndex?: boolean; } export const ContainerPolicyComponent: React.FC = ({ diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx index e54f1459c..b3f36cbc6 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx @@ -9,7 +9,7 @@ describe("ScaleComponent", () => { collection: collection, database: undefined, isFixedContainer: false, - isGlobalSecondaryIndex: false, + isGlobalSecondaryIndexTarget: false, onThroughputChange: () => { return; }, diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx index 1a0aed184..fefa37dbb 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx @@ -23,7 +23,7 @@ export interface ScaleComponentProps { collection: ViewModels.Collection; database: ViewModels.Database; isFixedContainer: boolean; - isGlobalSecondaryIndex: boolean; + isGlobalSecondaryIndexTarget: boolean; onThroughputChange: (newThroughput: number) => void; throughput: number; throughputBaseline: number; @@ -147,7 +147,7 @@ export class ScaleComponent extends React.Component { throughputError={this.props.throughputError} instantMaximumThroughput={this.offer?.instantMaximumThroughput} softAllowedMaximumThroughput={this.offer?.softAllowedMaximumThroughput} - isGlobalSecondaryIndex={this.props.isGlobalSecondaryIndex} + isGlobalSecondaryIndexTarget={this.props.isGlobalSecondaryIndexTarget} /> ); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.test.tsx index a02d907b3..ced333abb 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.test.tsx @@ -44,7 +44,7 @@ describe("ThroughputInputAutoPilotV3Component", () => { }, instantMaximumThroughput: 5000, softAllowedMaximumThroughput: 1000000, - isGlobalSecondaryIndex: false, + isGlobalSecondaryIndexTarget: false, }; it("throughput input visible", () => { diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx index 816c05299..31cfa48e3 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx @@ -81,7 +81,7 @@ export interface ThroughputInputAutoPilotV3Props { throughputError?: string; instantMaximumThroughput: number; softAllowedMaximumThroughput: number; - isGlobalSecondaryIndex: boolean; + isGlobalSecondaryIndexTarget: boolean; } interface ThroughputInputAutoPilotV3State { @@ -427,7 +427,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< toolTipElement={getToolTipContainer(this.props.infoBubbleText)} /> - {!this.props.isGlobalSecondaryIndex && ( + {!this.props.isGlobalSecondaryIndexTarget && ( <> {this.overrideWithProvisionedThroughputSettings() && ( void; setIsAutoscale: (isAutoscale: boolean) => void; setIsThroughputCapExceeded: (isThroughputCapExceeded: boolean) => void; @@ -32,7 +32,7 @@ export const ThroughputInput: FunctionComponent = ({ isFreeTier, showFreeTierExceedThroughputTooltip, isQuickstart, - isGlobalSecondaryIndex, + isGlobalSecondaryIndexTarget, setThroughputValue, setIsAutoscale, setIsThroughputCapExceeded, @@ -202,7 +202,7 @@ export const ThroughputInput: FunctionComponent = ({ {PricingUtils.getRuToolTipText()} - {!isGlobalSecondaryIndex && ( + {!isGlobalSecondaryIndexTarget && (
void; - isGlobalSecondaryIndex?: boolean; + isGlobalSecondaryIndexTarget?: boolean; } export interface VectorEmbeddingPolicyData { @@ -95,7 +95,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent { const isExistingPolicy = (policy: VectorEmbeddingPolicyData): boolean => { if (!vectorEmbeddingsBaseline || vectorEmbeddingsBaseline.length === 0) { @@ -166,7 +166,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent { - const containerName = isGlobalSecondaryIndex + const containerName = isGlobalSecondaryIndexTarget ? t(Keys.controls.vectorEmbeddingPolicies.quantizationByteSizeTooltipGlobalSecondaryIndexName) : t(Keys.controls.vectorEmbeddingPolicies.quantizationByteSizeTooltipContainerName); return t(Keys.controls.vectorEmbeddingPolicies.quantizationByteSizeTooltip, { containerName }); diff --git a/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts b/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts index 7e90053fc..4a4b3468b 100644 --- a/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts +++ b/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts @@ -1,6 +1,5 @@ import { IDropdownOption } from "@fluentui/react"; import { VectorIndex } from "Contracts/DataModels"; -import { Keys, t } from "Localization"; const dataTypes = ["float32", "uint8", "int8", "float16"]; const distanceFunctions = ["euclidean", "cosine", "dotproduct"]; @@ -10,8 +9,8 @@ export const getDataTypeOptions = (): IDropdownOption[] => createDropdownOptions export const getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions); export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes); export const getQuantizerTypeOptions = (): IDropdownOption[] => [ + { key: "spherical", text: "Spherical" }, { key: "product", text: "Product" }, - { key: "spherical", text: `Spherical (${t(Keys.common.preview)})` }, ]; export const supportsQuantization = (indexType: VectorIndex["type"] | "none" | undefined): boolean => diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index bcacb8d65..bf1c5a33e 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -666,6 +666,10 @@ export default class Explorer { title = "Mongo Shell"; break; + case ViewModels.TerminalKind.CosmosDB: + title = "Cosmos DB Shell"; + break; + default: throw new Error("Terminal kind: ${kind} not supported"); } diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index 25ed4ea4f..911191e0d 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -17,11 +17,11 @@ import SynapseIcon from "../../../../images/synapse-link.svg"; import VSCodeIcon from "../../../../images/vscode.svg"; import { AuthType } from "../../../AuthType"; import * as Constants from "../../../Common/Constants"; -import { Platform, configContext } from "../../../ConfigContext"; +import { configContext, Platform } from "../../../ConfigContext"; import * as ViewModels from "../../../Contracts/ViewModels"; import { - userContext, isVCoreMongoNativeAuthDisabled, + userContext, VCoreMongoNativeAuthDisabledMessage, VCoreMongoNativeAuthLearnMoreUrl, } from "../../../UserContext"; @@ -81,6 +81,15 @@ export function createStaticCommandBarButtons( } } + if ( + userContext.apiType === "SQL" && + userContext.features.enableCloudShell && + userContext.features.enableCosmosDBShell + ) { + addDivider(); + buttons.push(createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.CosmosDB)); + } + if (!selectedNodeState.isDatabaseNodeOrNoneSelected()) { const isQuerySupported = userContext.apiType === "SQL" || userContext.apiType === "Gremlin"; @@ -498,6 +507,8 @@ function createOpenTerminalButtonByKind( return "PSQL"; case ViewModels.TerminalKind.VCoreMongo: return "MongoDB (DocumentDB)"; + case ViewModels.TerminalKind.CosmosDB: + return "Cosmos DB"; default: return ""; } @@ -507,7 +518,9 @@ function createOpenTerminalButtonByKind( "This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks."; const isNativeAuthDisabled = terminalKind === ViewModels.TerminalKind.VCoreMongo && isVCoreMongoNativeAuthDisabled(); const disableButton = - (!useNotebook.getState().isNotebooksEnabledForAccount && !useNotebook.getState().isNotebookEnabled) || + (!useNotebook.getState().isNotebooksEnabledForAccount && + !useNotebook.getState().isNotebookEnabled && + !userContext.features.enableCloudShell) || isNativeAuthDisabled; return { iconSrc: HostedTerminalIcon, diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx index f353f4f6a..d66f56ba0 100644 --- a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx @@ -88,12 +88,12 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel }); database.collections().forEach((collection: Collection) => { - const isGlobalSecondaryIndex: boolean = !!collection.materializedViewDefinition(); + const isGlobalSecondaryIndexTarget: boolean = !!collection.materializedViewDefinition(); sourceContainerOptions.push({ key: collection.rid, text: collection.id(), - disabled: isGlobalSecondaryIndex, - ...(isGlobalSecondaryIndex && { + disabled: isGlobalSecondaryIndexTarget, + ...(isGlobalSecondaryIndexTarget && { title: "This is a global secondary index.", }), data: collection, @@ -382,7 +382,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel setVectorIndexingPolicy, vectorPolicyValidated, setVectorPolicyValidated, - isGlobalSecondaryIndex: true, + isGlobalSecondaryIndexTarget: true, }} /> )} diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx index d4f9934bc..9cb698954 100644 --- a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx @@ -50,7 +50,7 @@ export const ThroughputComponent = (props: ThroughputComponentProps): JSX.Elemen isSharded={true} isFreeTier={isFreeTierAccount()} isQuickstart={false} - isGlobalSecondaryIndex={true} + isGlobalSecondaryIndexTarget={true} setThroughputValue={(throughput: number) => { globalSecondaryIndexThroughputOnChange(throughput); }} diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx index 7e35f211e..e3021c034 100644 --- a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx @@ -14,7 +14,7 @@ export interface VectorSearchComponentProps { vectorIndexingPolicy: VectorIndex[]; setVectorIndexingPolicy: React.Dispatch>; setVectorPolicyValidated: React.Dispatch>; - isGlobalSecondaryIndex?: boolean; + isGlobalSecondaryIndexTarget?: boolean; } export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.Element => { @@ -24,7 +24,7 @@ export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.El vectorIndexingPolicy, setVectorIndexingPolicy, setVectorPolicyValidated, - isGlobalSecondaryIndex, + isGlobalSecondaryIndexTarget, } = props; return ( @@ -52,7 +52,7 @@ export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.El setVectorIndexingPolicy(vectorIndexingPolicy); setVectorPolicyValidated(vectorPolicyValidated); }} - isGlobalSecondaryIndex={isGlobalSecondaryIndex} + isGlobalSecondaryIndexTarget={isGlobalSecondaryIndexTarget} /> diff --git a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx index 61daecd15..cc89cb34f 100644 --- a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx @@ -127,6 +127,7 @@ describe("CloudShellClient", () => { vnetSettings: {}, }, }, + timeoutMs: 30000, }); expect(result).toEqual(mockResponse); }); @@ -154,6 +155,7 @@ describe("CloudShellClient", () => { vnetSettings: mockVNetSettings, }, }, + timeoutMs: 30000, }); }); @@ -212,6 +214,7 @@ describe("CloudShellClient", () => { path: "/subscriptions/sub-id/providers/Microsoft.CloudShell/register", method: "POST", apiVersion: "2022-12-01", + timeoutMs: 30000, }); expect(result).toEqual(mockResponse); }); @@ -227,6 +230,7 @@ describe("CloudShellClient", () => { path: "/subscriptions/sub-id/providers/Microsoft.CloudShell/register", method: "POST", apiVersion: "2022-12-01", + timeoutMs: 30000, }); }); }); @@ -251,6 +255,7 @@ describe("CloudShellClient", () => { osType: OsType.Linux, }, }, + timeoutMs: 30000, }); expect(result).toEqual(mockResponse); }); @@ -274,6 +279,7 @@ describe("CloudShellClient", () => { osType: OsType.Linux, }, }, + timeoutMs: 30000, }); }); }); diff --git a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx index ee4bd01e0..2f7926cae 100644 --- a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx @@ -14,6 +14,13 @@ import { } from "../Models/DataModels"; import { getLocale } from "../Utils/CommonUtils"; +// armRequest defaults to a 5s timeout with no retry for PUT/POST. Cloud Shell provisioning +// (registering the provider, applying settings, and provisioning the console itself) can +// legitimately take much longer than that on first use or when the console region differs +// from the user's usual region, so a much more generous timeout is used for these calls to +// avoid a spurious AbortError ("User aborted query.") aborting the whole session start. +const CLOUDSHELL_ARM_TIMEOUT_MS = 30000; + export const getUserSettings = async (): Promise => { return await armRequest({ host: configContext.ARM_ENDPOINT, @@ -51,6 +58,7 @@ export const putEphemeralUserSettings = async ( method: "PUT", apiVersion: "2023-02-01-preview", body: ephemeralSettings, + timeoutMs: CLOUDSHELL_ARM_TIMEOUT_MS, }); }; @@ -69,6 +77,7 @@ export const registerCloudShellProvider = async (subscriptionId: string) => { path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell/register`, method: "POST", apiVersion: "2022-12-01", + timeoutMs: CLOUDSHELL_ARM_TIMEOUT_MS, }); }; @@ -88,6 +97,7 @@ export const provisionConsole = async (consoleLocation: string): Promise ({ + userContext: { + databaseAccount: { + properties: { + documentEndpoint: "https://test-account.documents.azure.com:443/", + }, + }, + }, +})); + +describe("CosmosDBShellHandler", () => { + const mockKey = "testKey"; + const endpoint = "https://test-account.documents.azure.com:443/"; + const tokenConnectionCommand = `export COSMOSDB_SHELL_TOKEN='aadToken123'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`; + const keyConnectionCommand = `export COSMOSDB_SHELL_ACCOUNT_KEY='${mockKey}'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`; + let cosmosDBShellHandler: CosmosDBShellHandler; + + beforeEach(() => { + cosmosDBShellHandler = new CosmosDBShellHandler({ kind: "key", value: mockKey }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.resetModules(); + }); + + describe("Positive Tests", () => { + it("should return correct shell name", () => { + expect(cosmosDBShellHandler.getShellName()).toBe("Cosmos DB"); + }); + + it("should install the CosmosDBShell tool in setup commands", () => { + const commands = cosmosDBShellHandler.getSetUpCommands(); + + expect(Array.isArray(commands)).toBe(true); + expect(commands.some((c) => c.includes("dotnet tool install --global CosmosDBShell --prerelease"))).toBe(true); + expect(commands.some((c) => c.includes("dotnet tool update --global CosmosDBShell --prerelease"))).toBe(true); + expect(commands.some((c) => c.includes("$HOME/.dotnet/tools"))).toBe(true); + }); + + it("should bootstrap a .NET SDK 10 when the tool and SDK are missing", () => { + const commands = cosmosDBShellHandler.getSetUpCommands(); + + expect(commands.some((c) => c.includes("dotnet-install.sh") && c.includes("--channel 10.0"))).toBe(true); + expect(commands.some((c) => c === "export DOTNET_ROOT=$HOME/.dotnet")).toBe(true); + }); + + it("should not export any credential env var in setup commands for a key credential", () => { + const commands = cosmosDBShellHandler.getSetUpCommands(); + + expect(commands.some((c) => c.includes("COSMOSDB_SHELL_"))).toBe(false); + }); + + it("should not export any credential env var in setup commands for a token credential", () => { + const handler = new CosmosDBShellHandler({ kind: "token", value: "aadToken123" }); + const commands = handler.getSetUpCommands(); + + expect(commands.some((c) => c.includes("COSMOSDB_SHELL_"))).toBe(false); + }); + + it("should export the account key and connect to the bare endpoint on the same command line", () => { + expect(cosmosDBShellHandler.getConnectionCommand()).toBe(keyConnectionCommand); + }); + + it("should export the token and connect to the bare endpoint on the same command line", () => { + const handler = new CosmosDBShellHandler({ kind: "token", value: "aadToken123" }); + + expect(handler.getConnectionCommand()).toBe(tokenConnectionCommand); + }); + + it("should never pass an interactive or ambient credential flag", () => { + const handler = new CosmosDBShellHandler({ kind: "token", value: "aadToken123" }); + const connectionCommand = handler.getConnectionCommand(); + + expect(connectionCommand).not.toContain("--connect-tenant"); + expect(connectionCommand).not.toContain("--connect-hint"); + expect(connectionCommand).not.toContain("--connect-authority-host"); + expect(connectionCommand).not.toContain("--connect-azure-cli"); + expect(connectionCommand).not.toContain("--connect-managed-identity"); + expect(connectionCommand).not.toContain("--connect-vscode-credential"); + }); + + it("should return empty array for terminal suppressed data", () => { + expect(cosmosDBShellHandler.getTerminalSuppressedData()).toEqual([]); + }); + }); + + describe("Negative Tests", () => { + it("should not export a credential env var when no credential was resolved", () => { + const handler = new CosmosDBShellHandler(undefined); + const commands = handler.getSetUpCommands(); + + expect(commands.some((c) => c.includes("COSMOSDB_SHELL_"))).toBe(false); + }); + + it("should not launch the shell when no credential was resolved", () => { + const handler = new CosmosDBShellHandler(undefined); + const connectionCommand = handler.getConnectionCommand(); + + expect(connectionCommand).not.toContain("cosmosdbshell --connect"); + expect(connectionCommand).toContain("Unable to acquire a Cosmos DB credential"); + expect(connectionCommand).toContain("Login for Entra ID"); + }); + + it("should echo the specific reason when one is provided", () => { + const handler = new CosmosDBShellHandler(undefined, "listing the account keys failed (Forbidden)"); + const connectionCommand = handler.getConnectionCommand(); + + expect(connectionCommand).toContain("Unable to acquire a Cosmos DB credential"); + expect(connectionCommand).toContain("listing the account keys failed (Forbidden)"); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx new file mode 100644 index 000000000..ed1b9658d --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx @@ -0,0 +1,129 @@ +import { userContext } from "../../../../UserContext"; +import { AbstractShellHandler } from "./AbstractShellHandler"; + +/** + * A credential resolved by Data Explorer and handed to the Cosmos DB Shell. + * + * Both kinds are delivered the same way the Mongo shell delivers its credential: exported + * as an environment variable on the same command line as the `cosmosdbshell` invocation + * (`export VAR='...'; cosmosdbshell --connect `), immediately before the tool + * reads it, rather than as a separate setup step or embedded in a connection string. + * + * - `token` is an Entra ID (AAD) bearer token scoped to the account's data plane, exported + * as COSMOSDB_SHELL_TOKEN. + * - `key` is an account master (or read-only) key, exported as COSMOSDB_SHELL_ACCOUNT_KEY. + * + * The kind is tracked explicitly so the correct environment variable is always used. + */ +export interface CosmosDBShellCredential { + kind: "token" | "key"; + value: string; +} + +/** + * Shell handler for the Azure Cosmos DB Shell (https://github.com/Azure/CosmosDBShell), + * a .NET global tool that targets the Cosmos DB NoSQL (SQL Core) API. + */ +export class CosmosDBShellHandler extends AbstractShellHandler { + private _endpoint: string | undefined; + + constructor( + private credential: CosmosDBShellCredential | undefined, + private unavailableReason?: string, + ) { + super(); + this._endpoint = userContext?.databaseAccount?.properties?.documentEndpoint; + } + + public getShellName(): string { + return "Cosmos DB"; + } + + /** + * Setup commands for the Cosmos DB Shell: + * + * 1. Put the private .NET install dir and global tools dir on PATH (and set + * DOTNET_ROOT) so both the `dotnet` host and installed tool resolve. + * 2. Bootstrap a private .NET SDK 10 into $HOME/.dotnet when neither the + * cosmosdbshell tool nor a suitable SDK is already available. The + * CosmosDBShell global tool targets net10.0, so `dotnet tool install` + * requires the .NET SDK 10.0+, which the Azure Cloud Shell host does not + * ship by default. + * 3. Install the CosmosDBShell global tool if it is not already available, or + * update it to the latest release when it is (so an older cached install in + * the persistent Cloud Shell $HOME picks up newer connect options). + * 4. Persist the PATH/DOTNET_ROOT changes for future sessions. + * + * The credential itself is not exported here: it travels on the same command line as + * the `cosmosdbshell` invocation (see getConnectionCommand), mirroring how the Mongo + * shell handler builds its connection command. + * + * Installation steps run conditionally only if cosmosdbshell is not already + * present in the environment. + */ + public getSetUpCommands(): string[] { + return [ + "export DOTNET_ROOT=$HOME/.dotnet", + "export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH", + "if ! command -v cosmosdbshell &> /dev/null; then echo '⚠️ cosmosdbshell not found. Installing .NET SDK 10 and CosmosDBShell...'; fi", + "if ! command -v cosmosdbshell &> /dev/null && ! dotnet --list-sdks 2>/dev/null | grep -q '^10\\.'; then curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir $HOME/.dotnet; fi", + "if ! command -v cosmosdbshell &> /dev/null; then dotnet tool install --global CosmosDBShell --prerelease; else dotnet tool update --global CosmosDBShell --prerelease; fi", + "grep -qxF 'export DOTNET_ROOT=$HOME/.dotnet' ~/.bashrc || echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.bashrc", + "grep -qxF 'export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH' ~/.bashrc || echo 'export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH' >> ~/.bashrc", + ]; + } + + private _getKeyConnectionCommand(key: string): string { + // Export the key immediately before invoking the tool, on the same command line, so it + // never appears as a --connect flag value or lands in a separate setup step. The tool + // reads the account key from COSMOSDB_SHELL_ACCOUNT_KEY and connects to the bare endpoint. + return `export COSMOSDB_SHELL_ACCOUNT_KEY='${key}'; cosmosdbshell --connect '${this._endpoint}' --connect-mode gateway --verbose`; + } + + private _getTokenConnectionCommand(token: string): string { + // Same pattern for the Entra ID token: exported right before the invocation so it never + // appears in argv/ps, then the tool reads it from COSMOSDB_SHELL_TOKEN. + return `export COSMOSDB_SHELL_TOKEN='${token}'; cosmosdbshell --connect '${this._endpoint}' --connect-mode gateway --verbose`; + } + + public getConnectionCommand(): string { + if (!this._endpoint) { + return `echo '${this.getShellName()} endpoint not found.'`; + } + + if (!this.credential) { + // Never let the tool continue without a credential. With no account key and no + // COSMOSDB_SHELL_TOKEN exported, its credential chain would fall through to + // DefaultAzureCredential, which in Azure Cloud Shell tries the managed identity + // first and fails with "AudienceNotSupported" (the Cloud Shell MSI cannot mint a + // token for the *.documents.azure.com audience). The interactive browser and + // device-code flows are not usable from this embedded terminal either, so fail + // fast with actionable guidance instead. + // + // There is no way to inspect the browser dev tools console from inside this remote + // terminal, so the specific reason (when known) is echoed here too. + const reasonSuffix = this.unavailableReason ? ` — ${this.unavailableReason}` : ""; + return `echo 'Unable to acquire a ${this.getShellName()} credential${reasonSuffix}. Use "Login for Entra ID" in the Data Explorer toolbar, verify you have Cosmos DB data-plane RBAC access to this account, then reopen the shell.'`; + } + + // Force gateway (HTTPS/443) connection mode. The shell otherwise defaults to + // direct (TCP) mode for real accounts, and Azure Cloud Shell blocks the + // direct-mode TCP ports, causing the connection to fail. `--verbose` surfaces + // the full exception details when a connection attempt fails. + // + // Surface which credential kind Explorer resolved in the browser console. A wrong + // guess here (e.g. a token when the account actually needs a key) otherwise fails + // silently inside the remote shell with no client-side signal to debug against. + console.warn(`CloudShell: connecting to Cosmos DB shell with a "${this.credential.kind}" credential`, { + endpoint: this._endpoint, + }); + + return this.credential.kind === "key" + ? this._getKeyConnectionCommand(this.credential.value) + : this._getTokenConnectionCommand(this.credential.value); + } + + public getTerminalSuppressedData(): string[] { + return []; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.test.tsx index 807844514..2096ef1b1 100644 --- a/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.test.tsx +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.test.tsx @@ -1,19 +1,23 @@ import { TerminalKind } from "../../../../Contracts/ViewModels"; import { userContext } from "../../../../UserContext"; -import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { getReadOnlyKeys, listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { acquireMsalTokenForAccount, getMsalInstance } from "../../../../Utils/AuthorizationUtils"; import { CassandraShellHandler } from "./CassandraShellHandler"; +import { CosmosDBShellHandler } from "./CosmosDBShellHandler"; import { MongoShellHandler } from "./MongoShellHandler"; import { PostgresShellHandler } from "./PostgresShellHandler"; -import { getHandler, getKey } from "./ShellTypeFactory"; +import type { CosmosDBShellCredentialDiagnostics } from "./ShellTypeFactory"; +import { getCosmosDBShellCredential, getHandler, getKey } from "./ShellTypeFactory"; import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler"; interface UserContextType { - databaseAccount: { name: string }; + databaseAccount: { name: string; properties?: { disableLocalAuth?: boolean } }; subscriptionId: string; resourceGroup: string; features: { enableAadDataPlane: boolean }; dataPlaneRbacEnabled: boolean; aadToken?: string; + masterKey?: string; apiType?: string; } @@ -30,13 +34,29 @@ jest.mock("../../../../UserContext", () => ({ jest.mock("../../../../Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({ listKeys: jest.fn(), + getReadOnlyKeys: jest.fn(), })); +jest.mock("../../../../Utils/AuthorizationUtils", () => { + const actual = jest.requireActual("../../../../Utils/AuthorizationUtils"); + return { + ...actual, + getMsalInstance: jest.fn(), + acquireMsalTokenForAccount: jest.fn(), + }; +}); + describe("ShellTypeHandlerFactory", () => { const mockKey = "testKey"; + const mockMsalAccounts = (accounts: { username: string }[]): void => { + (getMsalInstance as jest.Mock).mockResolvedValue({ getAllAccounts: (): { username: string }[] => accounts }); + }; + beforeEach(() => { (listKeys as jest.Mock).mockResolvedValue({ primaryMasterKey: mockKey }); + mockMsalAccounts([]); + (acquireMsalTokenForAccount as jest.Mock).mockResolvedValue(""); }); afterEach(() => { @@ -69,7 +89,7 @@ describe("ShellTypeHandlerFactory", () => { type DatabaseAccountType = { name: string }; (userContext.databaseAccount as DatabaseAccountType).name = ""; - const key = await getKey(); + const key = await getKey(false); expect(key).toBe(""); expect(listKeys).not.toHaveBeenCalled(); @@ -80,7 +100,7 @@ describe("ShellTypeHandlerFactory", () => { it("should return empty string when listKeys returns null", async () => { (listKeys as jest.Mock).mockResolvedValue(null); - const key = await getKey(); + const key = await getKey(false); expect(key).toBe(""); }); @@ -89,7 +109,7 @@ describe("ShellTypeHandlerFactory", () => { /* no primaryMasterKey */ }); - const key = await getKey(); + const key = await getKey(false); expect(key).toBe(""); }); }); @@ -116,12 +136,57 @@ describe("ShellTypeHandlerFactory", () => { expect(handler).toBeInstanceOf(CassandraShellHandler); }); + it("should return CosmosDBShellHandler with key for CosmosDB terminal kind", async () => { + const handler = await getHandler(TerminalKind.CosmosDB); + expect(handler).toBeInstanceOf(CosmosDBShellHandler); + }); + it("should get key successfully when database name exists", async () => { - const key = await getKey(); + const key = await getKey(false); expect(key).toBe(mockKey); expect(listKeys).toHaveBeenCalledWith("testSubId", "testResourceGroup", "testDbName"); }); + it("should return the aadToken without listing keys when Entra ID auth is requested", async () => { + (userContext as UserContextType).aadToken = "aadToken123"; + + const key = await getKey(true); + expect(key).toBe("aadToken123"); + expect(listKeys).not.toHaveBeenCalled(); + }); + + it("should return an empty string when Entra ID auth is requested but no cached token or account exists", async () => { + (userContext as UserContextType).aadToken = undefined; + mockMsalAccounts([]); + + const key = await getKey(true); + expect(key).toBe(""); + expect(acquireMsalTokenForAccount).not.toHaveBeenCalled(); + expect(listKeys).not.toHaveBeenCalled(); + }); + + it("should silently mint a Cosmos token when Entra ID auth is requested and an MSAL account is cached", async () => { + (userContext as UserContextType).aadToken = undefined; + mockMsalAccounts([{ username: "user@contoso.com" }]); + (acquireMsalTokenForAccount as jest.Mock).mockResolvedValue("mintedToken123"); + + const key = await getKey(true); + expect(key).toBe("mintedToken123"); + expect(acquireMsalTokenForAccount).toHaveBeenCalled(); + expect(listKeys).not.toHaveBeenCalled(); + }); + + it("should return an empty string when the silent token acquisition fails", async () => { + (userContext as UserContextType).aadToken = undefined; + mockMsalAccounts([{ username: "user@contoso.com" }]); + (acquireMsalTokenForAccount as jest.Mock).mockRejectedValue(new Error("interaction_required")); + jest.spyOn(console, "error").mockImplementation(() => undefined); + + const key = await getKey(true); + expect(key).toBe(""); + expect(listKeys).not.toHaveBeenCalled(); + }); + it("should return MongoShellHandler with primaryMasterKey for TerminalKind.Mongo when RBAC is disabled", async () => { (listKeys as jest.Mock).mockResolvedValue({ primaryMasterKey: "primaryKey123" }); (userContext as UserContextType).features.enableAadDataPlane = false; @@ -150,4 +215,148 @@ describe("ShellTypeHandlerFactory", () => { ); }); }); + + describe("getCosmosDBShellCredential", () => { + beforeEach(() => { + (userContext as UserContextType).aadToken = undefined; + (userContext as UserContextType).masterKey = undefined; + (userContext as UserContextType).apiType = "SQL"; + (userContext as UserContextType).features.enableAadDataPlane = false; + (userContext as UserContextType).dataPlaneRbacEnabled = false; + (userContext as UserContextType).databaseAccount.properties = { disableLocalAuth: false }; + }); + + it("should reuse the master key Data Explorer already resolved without calling ARM", async () => { + (userContext as UserContextType).masterKey = "cachedMasterKey"; + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toEqual({ kind: "key", value: "cachedMasterKey" }); + expect(listKeys).not.toHaveBeenCalled(); + expect(getReadOnlyKeys).not.toHaveBeenCalled(); + }); + + it("should fall back to the read-only keys when the caller lacks list-keys permission", async () => { + const authorizationFailed = Object.assign(new Error("AuthorizationFailed"), { code: "AuthorizationFailed" }); + (listKeys as jest.Mock).mockRejectedValue(authorizationFailed); + (getReadOnlyKeys as jest.Mock).mockResolvedValue({ primaryReadonlyMasterKey: "readOnlyKey" }); + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toEqual({ kind: "key", value: "readOnlyKey" }); + expect(getReadOnlyKeys).toHaveBeenCalledWith("testSubId", "testResourceGroup", "testDbName"); + }); + + it("should return undefined when both the read-write and read-only key fetches fail", async () => { + const authorizationFailed = Object.assign(new Error("AuthorizationFailed"), { code: "AuthorizationFailed" }); + (listKeys as jest.Mock).mockRejectedValue(authorizationFailed); + (getReadOnlyKeys as jest.Mock).mockRejectedValue(new Error("Forbidden")); + jest.spyOn(console, "error").mockImplementation(() => undefined); + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toBeUndefined(); + }); + + it("should return the account key when Entra ID auth is not enabled", async () => { + const credential = await getCosmosDBShellCredential(); + + expect(credential).toEqual({ kind: "key", value: mockKey }); + expect(listKeys).toHaveBeenCalledWith("testSubId", "testResourceGroup", "testDbName"); + }); + + it("should return the cached aadToken when Entra ID auth is enabled", async () => { + (userContext as UserContextType).dataPlaneRbacEnabled = true; + (userContext as UserContextType).aadToken = "aadToken123"; + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toEqual({ kind: "token", value: "aadToken123" }); + expect(listKeys).not.toHaveBeenCalled(); + }); + + it("should return a silently minted token when an MSAL account is cached", async () => { + (userContext as UserContextType).dataPlaneRbacEnabled = true; + mockMsalAccounts([{ username: "user@contoso.com" }]); + (acquireMsalTokenForAccount as jest.Mock).mockResolvedValue("mintedToken123"); + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toEqual({ kind: "token", value: "mintedToken123" }); + expect(listKeys).not.toHaveBeenCalled(); + }); + + it("should fall back to the account key when no token could be resolved", async () => { + (userContext as UserContextType).dataPlaneRbacEnabled = true; + + const credential = await getCosmosDBShellCredential(); + + expect(acquireMsalTokenForAccount).not.toHaveBeenCalled(); + expect(credential).toEqual({ kind: "key", value: mockKey }); + }); + + it("should fall back to the account key when the silent token acquisition throws", async () => { + (userContext as UserContextType).dataPlaneRbacEnabled = true; + mockMsalAccounts([{ username: "user@contoso.com" }]); + (acquireMsalTokenForAccount as jest.Mock).mockRejectedValue(new Error("interaction_required")); + jest.spyOn(console, "error").mockImplementation(() => undefined); + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toEqual({ kind: "key", value: mockKey }); + }); + + it("should not fall back to the account key when local auth is disabled", async () => { + (userContext as UserContextType).databaseAccount.properties = { disableLocalAuth: true }; + jest.spyOn(console, "warn").mockImplementation(() => undefined); + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toBeUndefined(); + expect(listKeys).not.toHaveBeenCalled(); + }); + + it("should return undefined when listing the account keys fails", async () => { + (listKeys as jest.Mock).mockRejectedValue(new Error("Forbidden")); + jest.spyOn(console, "error").mockImplementation(() => undefined); + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toBeUndefined(); + }); + + it("should return undefined when the database name is missing", async () => { + (userContext as UserContextType).databaseAccount.name = ""; + + const credential = await getCosmosDBShellCredential(); + + expect(credential).toBeUndefined(); + expect(listKeys).not.toHaveBeenCalled(); + + (userContext as UserContextType).databaseAccount.name = "testDbName"; + }); + + it("should populate a specific reason when both key fetches fail, for the shell to echo", async () => { + (listKeys as jest.Mock).mockRejectedValue(new Error("Forbidden")); + (getReadOnlyKeys as jest.Mock).mockRejectedValue(new Error("Forbidden")); + jest.spyOn(console, "error").mockImplementation(() => undefined); + + const diagnostics: CosmosDBShellCredentialDiagnostics = {}; + const credential = await getCosmosDBShellCredential(diagnostics); + + expect(credential).toBeUndefined(); + expect(diagnostics.reason).toContain("Forbidden"); + }); + + it("should populate a reason when local auth is disabled and no token was resolved", async () => { + (userContext as UserContextType).databaseAccount.properties = { disableLocalAuth: true }; + jest.spyOn(console, "warn").mockImplementation(() => undefined); + + const diagnostics: CosmosDBShellCredentialDiagnostics = {}; + const credential = await getCosmosDBShellCredential(diagnostics); + + expect(credential).toBeUndefined(); + expect(diagnostics.reason).toContain("local (key) auth is disabled"); + }); + }); }); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx index 81e068562..0c3be0be6 100644 --- a/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx @@ -1,9 +1,15 @@ import { TerminalKind } from "../../../../Contracts/ViewModels"; import { userContext } from "../../../../UserContext"; -import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; -import { isDataplaneRbacEnabledForProxyApi } from "../../../../Utils/AuthorizationUtils"; +import { getReadOnlyKeys, listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { + acquireMsalTokenForAccount, + getMsalInstance, + isCloudShellEntraAuthEnabled, + isDataplaneRbacEnabledForProxyApi, +} from "../../../../Utils/AuthorizationUtils"; import { AbstractShellHandler } from "./AbstractShellHandler"; import { CassandraShellHandler } from "./CassandraShellHandler"; +import { CosmosDBShellCredential, CosmosDBShellHandler } from "./CosmosDBShellHandler"; import { MongoShellHandler } from "./MongoShellHandler"; import { PostgresShellHandler } from "./PostgresShellHandler"; import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler"; @@ -16,23 +22,244 @@ export async function getHandler(shellType: TerminalKind): Promise { +/** + * Populated by {@link getCosmosDBShellCredential} (and its helpers) with a human-readable + * explanation when no credential could be resolved, so the shell can echo a specific cause + * instead of a generic message. There is no way to inspect the browser dev tools console + * from inside the remote Cloud Shell terminal, so this is surfaced directly in the shell. + */ +export interface CosmosDBShellCredentialDiagnostics { + reason?: string; +} + +/** + * Resolves the credential Data Explorer already holds for the current account and hands + * it to the Cosmos DB Shell. + * + * The Cloud Shell cannot authenticate to Cosmos DB on its own: its managed identity is + * rejected with "AudienceNotSupported" for the `*.documents.azure.com` audience, its `az` + * session is not signed in, and the interactive browser/device-code flows are not usable + * from the embedded terminal. The credential therefore has to come from Data Explorer. + * + * Resolution order: + * 1. Entra ID data-plane token — the cached `userContext.aadToken`, or a silently minted + * one. Silent acquisition is only attempted when an MSAL account is already cached so + * it can never trigger an interactive popup. + * 2. Account master key — the key Data Explorer already resolved (`userContext.masterKey`), + * or, if that is not populated, one fetched via ARM (`listKeys`, falling back to + * `getReadOnlyKeys` on any failure for read-only callers). The key is handed to the + * handler, which exports it as COSMOSDB_SHELL_ACCOUNT_KEY on the same command line as + * the `cosmosdbshell` invocation. Skipped when the account has local auth disabled + * (keys do not exist). + * + * Returns `undefined` when nothing could be resolved, which makes the handler surface + * actionable guidance instead of letting the tool attempt its own sign-in. + */ +export async function getCosmosDBShellCredential( + diagnostics?: CosmosDBShellCredentialDiagnostics, +): Promise { + const dbName = userContext.databaseAccount?.name; + if (!dbName) { + if (diagnostics) { + diagnostics.reason = "no database account name is available"; + } + return undefined; + } + + if (isCloudShellEntraAuthEnabled(userContext)) { + const token = await resolveCosmosDataPlaneToken(); + if (token) { + return { kind: "token", value: token }; + } + } + + if (userContext.databaseAccount?.properties?.disableLocalAuth) { + const reason = + "local (key) auth is disabled on this account and no Entra ID token could be resolved; sign in via " + + '"Login for Entra ID" in the toolbar and reopen the shell'; + console.warn(`CloudShell: ${reason}.`); + if (diagnostics) { + diagnostics.reason = reason; + } + return undefined; + } + + const key = await resolveAccountKey(dbName, diagnostics); + if (!key) { + if (!diagnostics?.reason) { + const reason = + "no account key could be resolved. Data Explorer may be authenticating through the portal proxy " + + "(per-request tokens), which cannot be reused by the shell. Ensure you have either data-plane RBAC " + + 'access (then use "Login for Entra ID") or permission to list the account keys'; + console.warn(`CloudShell: ${reason}.`); + if (diagnostics) { + diagnostics.reason = reason; + } + } + return undefined; + } + return { kind: "key", value: key }; +} + +/** + * Returns a Cosmos data-plane token without ever prompting. Silent acquisition is guarded + * on an existing cached MSAL account because `acquireMsalTokenForAccount` falls back to an + * interactive popup when none exists, which cannot complete in the hosted Cloud Shell. + */ +async function resolveCosmosDataPlaneToken(): Promise { + if (userContext.aadToken) { + return userContext.aadToken; + } + + try { + const msalInstance = await getMsalInstance(); + if (msalInstance.getAllAccounts().length === 0) { + return ""; + } + return (await acquireMsalTokenForAccount(userContext.databaseAccount, true)) || ""; + } catch (error) { + console.error("Failed to silently acquire a Cosmos data-plane token for the Cloud Shell", error); + return ""; + } +} + +/** + * Resolves the subscription id and resource group for the current account. These are + * normally populated on `userContext`, but in some hosting contexts they can be missing, + * so they are parsed from the account's ARM resource id as a fallback. The id has the form + * `/subscriptions//resourceGroups//providers/Microsoft.DocumentDB/databaseAccounts/`. + */ +function resolveAccountArmScope(): { subscriptionId: string; resourceGroup: string } { + let subscriptionId = userContext.subscriptionId; + let resourceGroup = userContext.resourceGroup; + + const accountId = userContext.databaseAccount?.id; + if ((!subscriptionId || !resourceGroup) && accountId) { + const match = accountId.match(/\/subscriptions\/([^/]+)\/resourceGroups\/([^/]+)\//i); + if (match) { + subscriptionId = subscriptionId || match[1]; + resourceGroup = resourceGroup || match[2]; + } + } + + return { subscriptionId, resourceGroup }; +} + +async function resolveAccountKey(dbName: string, diagnostics?: CosmosDBShellCredentialDiagnostics): Promise { + // Prefer the key Data Explorer already resolved for this account so the shell reuses the + // exact credential DE is connected with and avoids a redundant ARM round-trip. + if (userContext.masterKey) { + return userContext.masterKey; + } + + // Otherwise fetch it via ARM, mirroring DE's own `fetchAndUpdateKeys`: try the read-write + // keys first, then fall back to the read-only keys. The fallback is attempted on any + // failure (not just "AuthorizationFailed") because a read-only caller can surface the + // missing-permission error in different shapes; without it such a user gets no credential + // and the shell cannot connect. + const { subscriptionId, resourceGroup } = resolveAccountArmScope(); + if (!subscriptionId || !resourceGroup) { + const reason = "the subscription id or resource group for this account could not be determined"; + console.error(`CloudShell: cannot list Cosmos DB account keys because ${reason}.`); + if (diagnostics) { + diagnostics.reason = reason; + } + return ""; + } + + let listKeysErrorMessage: string | undefined; + try { + const keys = await listKeys(subscriptionId, resourceGroup, dbName); + if (keys?.primaryMasterKey) { + return keys.primaryMasterKey; + } + listKeysErrorMessage = "the response did not include a usable key"; + } catch (error) { + listKeysErrorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to list read-write account keys for the Cloud Shell; trying read-only keys", error); + } + + try { + const readOnlyKeys = await getReadOnlyKeys(subscriptionId, resourceGroup, dbName); + if (readOnlyKeys?.primaryReadonlyMasterKey) { + return readOnlyKeys.primaryReadonlyMasterKey; + } + if (diagnostics) { + diagnostics.reason = + `listing account keys failed (${listKeysErrorMessage}) and the read-only keys response did not ` + + "include a usable key either"; + } + return ""; + } catch (readOnlyError) { + console.error("Failed to list read-only account keys for the Cloud Shell", readOnlyError); + if (diagnostics) { + const readOnlyErrorMessage = readOnlyError instanceof Error ? readOnlyError.message : String(readOnlyError); + diagnostics.reason = + `listing the account keys failed (${listKeysErrorMessage}) and listing the read-only keys also failed ` + + `(${readOnlyErrorMessage}) — this is usually a missing listKeys/listReadOnlyKeys RBAC permission`; + } + return ""; + } +} + +/** + * Resolves the credential to inject into the Cloud Shell for a Mongo or Cassandra + * connection. + * + * @param useEntraIdAuth When true, returns an Entra ID (AAD) bearer token scoped to + * the account's data plane; otherwise returns the account master key. The caller + * decides which credential applies so it stays in sync with the connection command + * the matching handler builds. + * + * The Cosmos DB (NoSQL) shell does not use this function — see + * {@link getCosmosDBShellCredential}, which also reports which kind of credential it + * resolved so the correct environment variable is exported. + */ +export async function getKey(useEntraIdAuth: boolean): Promise { const dbName = userContext.databaseAccount.name; if (!dbName) { return ""; } - if (isDataplaneRbacEnabledForProxyApi(userContext)) { - return userContext.aadToken || ""; + if (useEntraIdAuth) { + if (userContext.aadToken) { + return userContext.aadToken; + } + + try { + const msalInstance = await getMsalInstance(); + if (msalInstance.getAllAccounts().length === 0) { + // No cached account to silently acquire against; a token request here would + // require an interactive popup, which cannot complete in the Cloud Shell. + console.warn( + "CloudShell: no cached MSAL account available to mint a Cosmos data-plane token; " + + "the shell will use interactive device-code authentication.", + ); + return ""; + } + const token = (await acquireMsalTokenForAccount(userContext.databaseAccount, true)) || ""; + if (!token) { + console.warn("CloudShell: silent Cosmos data-plane token acquisition returned an empty token."); + } + return token; + } catch (error) { + console.error("Failed to silently acquire a Cosmos data-plane token for the Cloud Shell", error); + return ""; + } } const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.test.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.test.tsx new file mode 100644 index 000000000..a6186084c --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.test.tsx @@ -0,0 +1,67 @@ +import { Terminal } from "xterm"; +import { askConfirmation } from "./CommonUtils"; + +describe("askConfirmation", () => { + const createTerminalMock = () => { + let keyHandler: ((event: { key: string }) => void) | undefined; + const dispose = jest.fn(); + const terminal = { + writeln: jest.fn(), + focus: jest.fn(), + onKey: jest.fn((handler: (event: { key: string }) => void) => { + keyHandler = handler; + return { dispose }; + }), + } as unknown as Terminal; + + return { + terminal, + dispose, + pressKey: (key: string) => keyHandler?.({ key }), + }; + }; + + it("resolves true when the user presses Y", async () => { + const { terminal, dispose, pressKey } = createTerminalMock(); + const promise = askConfirmation(terminal, "Proceed?"); + + pressKey("Y"); + + await expect(promise).resolves.toBe(true); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it("resolves true when the user presses lowercase y", async () => { + const { terminal, pressKey } = createTerminalMock(); + const promise = askConfirmation(terminal, "Proceed?"); + + pressKey("y"); + + await expect(promise).resolves.toBe(true); + }); + + it("resolves false when the user presses N", async () => { + const { terminal, dispose, pressKey } = createTerminalMock(); + const promise = askConfirmation(terminal, "Proceed?"); + + pressKey("N"); + + await expect(promise).resolves.toBe(false); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it("ignores keys other than Y or N and keeps listening until a valid answer", async () => { + const { terminal, dispose, pressKey } = createTerminalMock(); + const promise = askConfirmation(terminal, "Proceed?"); + + pressKey("a"); + pressKey("1"); + pressKey("\r"); + expect(dispose).not.toHaveBeenCalled(); + + pressKey("y"); + + await expect(promise).resolves.toBe(true); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx index dcd6e8681..87759d572 100644 --- a/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx @@ -24,9 +24,15 @@ export const askConfirmation = async (terminal: Terminal, question: string): Pro terminal.focus(); return new Promise((resolve) => { const keyListener = terminal.onKey(({ key }: { key: string }) => { + const normalizedKey = key.toLowerCase(); + // Only "y" or "n" are accepted. Any other key is ignored so an accidental + // keypress does not abort the flow; keep listening until a valid answer. + if (normalizedKey !== "y" && normalizedKey !== "n") { + return; + } keyListener.dispose(); terminal.writeln(key); - return resolve(key.toLowerCase() === "y"); + return resolve(normalizedKey === "y"); }); }); }; @@ -46,6 +52,8 @@ export const getShellNameForDisplay = (terminalKind: TerminalKind): string => { case TerminalKind.Mongo: case TerminalKind.VCoreMongo: return "MongoDB"; + case TerminalKind.CosmosDB: + return "Cosmos DB"; default: return ""; } diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index 6ac748936..9b1299b06 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -990,6 +990,8 @@ "quantizationByteSizeTooltipContainerName": "container", "quantizationByteSizeTooltipGlobalSecondaryIndexName": "global secondary index", "quantizerType": "Quantizer type", + "quantizerTypeProduct": "Product", + "quantizerTypeSpherical": "Spherical", "quantizerTypeTooltip": "The quantization method used by the vector index.", "indexingSearchListSize": "Indexing search list size", "vectorIndexShardKey": "Vector index shard key", diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 88aaac1ca..d8ccecae0 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -35,6 +35,7 @@ export type Features = { readonly disableConnectionStringLogin: boolean; readonly enableContainerCopy: boolean; readonly enableCloudShell: boolean; + readonly enableCosmosDBShell: boolean; readonly enableRestoreContainer: boolean; // only for Fabric readonly mongoDisableNativeAuth: boolean; @@ -104,6 +105,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear enableContainerCopy: "true" === get("enablecontainercopy"), enableRestoreContainer: "true" === get("enablerestorecontainer"), enableCloudShell: true, + enableCosmosDBShell: "true" === get("enablecosmosdbshell"), mongoDisableNativeAuth: "true" === get("mongodisablenativeauth"), }; } diff --git a/src/Utils/AuthorizationUtils.test.ts b/src/Utils/AuthorizationUtils.test.ts index 68d448ba7..fd361ac22 100644 --- a/src/Utils/AuthorizationUtils.test.ts +++ b/src/Utils/AuthorizationUtils.test.ts @@ -17,6 +17,7 @@ describe("AuthorizationUtils", () => { updateUserContext({ features: { enableContainerCopy: false, + enableCosmosDBShell: false, enableAadDataPlane: enabled, canExceedMaximumValue: false, cosmosdb: false, diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts index 92b6b4a53..0938c6f33 100644 --- a/src/Utils/AuthorizationUtils.ts +++ b/src/Utils/AuthorizationUtils.ts @@ -236,3 +236,21 @@ export function useDataplaneRbacAuthorization(userContext: UserContext): boolean export function isDataplaneRbacEnabledForProxyApi(userContext: UserContext): boolean { return useDataplaneRbacAuthorization(userContext) && hasProxyServer(userContext.apiType); } + +/** + * Determines whether Cloud Shell connections should authenticate with an Entra ID + * token (COSMOSDB_SHELL_TOKEN) instead of the account master key. + * + * Returns true when data-plane RBAC authorization is in effect, or when the account + * has local (key) auth disabled — in the latter case a master key would be rejected + * by the service, so we must fall back to Entra ID even if the RBAC toggle is off. + * + * Unlike {@link isDataplaneRbacEnabledForProxyApi}, this does not require a proxy + * server, so it works for the SQL/CosmosDB API which connects directly to the + * data plane. + */ +export function isCloudShellEntraAuthEnabled(userContext: UserContext): boolean { + return ( + useDataplaneRbacAuthorization(userContext) || userContext.databaseAccount?.properties?.disableLocalAuth === true + ); +} diff --git a/test/NoSqlTestSetup.ts b/test/NoSqlTestSetup.ts deleted file mode 100644 index ea457fd55..000000000 --- a/test/NoSqlTestSetup.ts +++ /dev/null @@ -1,71 +0,0 @@ -export function getNoSqlRbacToken(): string | undefined { - let nosqlRbacToken: string | undefined; - const shardIndex = process.env.PLAYWRIGHT_SHARD_INDEX ?? ""; - switch (parseInt(shardIndex)) { - case 1: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_1_TOKEN; - break; - case 2: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_2_TOKEN; - break; - case 3: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_3_TOKEN; - break; - case 4: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_4_TOKEN; - break; - case 5: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_5_TOKEN; - break; - case 6: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_6_TOKEN; - break; - case 7: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_7_TOKEN; - break; - case 8: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_8_TOKEN; - break; - case 9: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_9_TOKEN; - break; - case 10: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_10_TOKEN; - break; - case 11: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_11_TOKEN; - break; - case 12: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_12_TOKEN; - break; - case 13: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_13_TOKEN; - break; - case 14: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_14_TOKEN; - break; - case 15: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_15_TOKEN; - break; - case 16: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_16_TOKEN; - break; - case 17: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_17_TOKEN; - break; - case 18: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_18_TOKEN; - break; - case 19: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_19_TOKEN; - break; - case 20: - nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_20_TOKEN; - break; - } - - if (!nosqlRbacToken) { - console.warn(`No NoSQL RBAC token found for shard index ${shardIndex}`); - } - return nosqlRbacToken; -} diff --git a/test/fx.ts b/test/fx.ts index 26b2893bc..880388a33 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -1,7 +1,6 @@ import { DefaultAzureCredential } from "@azure/identity"; import { Frame, Locator, Page, expect } from "@playwright/test"; import crypto from "crypto"; -import { getNoSqlRbacToken } from "./NoSqlTestSetup"; import { TestContainerContext } from "./testData"; const RETRY_COUNT = 3; @@ -45,29 +44,34 @@ export enum TestAccount { } export function getDefaultAccountName(accountType: TestAccount): string { + const accountNamePrefix = process.env.DE_ACCOUNT_PREFIX; + if (!accountNamePrefix) { + throw new Error("DE_ACCOUNT_PREFIX is not set"); + } + switch (accountType) { case TestAccount.Tables: - return "github-e2etests-tables"; + return `${accountNamePrefix}-de-test-table-1`; case TestAccount.Cassandra: - return "github-e2etests-cassandra"; + return `${accountNamePrefix}-de-test-cassandra-1`; case TestAccount.Gremlin: - return "github-e2etests-gremlin"; + return `${accountNamePrefix}-de-test-gremlin-1`; case TestAccount.Mongo: - return "github-e2etests-mongo"; + return `${accountNamePrefix}-de-test-mongo-1`; case TestAccount.MongoReadonly: - return "github-e2etests-mongo-readonly"; + return `${accountNamePrefix}-de-test-mongo-readonly`; case TestAccount.Mongo32: - return "github-e2etests-mongo32"; + return `${accountNamePrefix}-de-test-mongo32-1`; case TestAccount.SQLReadOnly: - return "github-e2etests-sql-readonly"; + return `${accountNamePrefix}-de-test-sql-readonly`; case TestAccount.SQLContainerCopyOnly: - return "github-e2etests-sql-containercopyonly"; + return `${accountNamePrefix}-de-test-sql-containercopy`; case TestAccount.SQL: { const shardIndex = process.env.PLAYWRIGHT_SHARD_INDEX ?? ""; if (!shardIndex) { throw new Error("PLAYWRIGHT_SHARD_INDEX is not set"); } - return "github-e2etests-sql-" + shardIndex; + return `${accountNamePrefix}-de-test-sql-${shardIndex}`; } default: throw new Error(`No default account name defined for account type ${accountType}`); @@ -75,7 +79,7 @@ export function getDefaultAccountName(accountType: TestAccount): string { } export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests"; -export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c"; +export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? process.env.AZURE_SUBSCRIPTION_ID ?? ""; export const TEST_AUTOSCALE_THROUGHPUT_RU = 1000; export const TEST_MANUAL_THROUGHPUT_RU = 800; export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K = 2000; @@ -117,11 +121,16 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes params.set("subscriptionId", subscriptionId); params.set("token", token); + const tenantId = process.env.AZURE_TENANT_ID; + if (tenantId) { + params.set("tenantId", tenantId); + } + // There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example) // For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false. params.set("feature.enableCopilot", "false"); - const nosqlRbacToken = getNoSqlRbacToken(); + const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN; const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN; const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN; diff --git a/test/mongo/pagination.spec.ts b/test/mongo/pagination.spec.ts index 548d49184..2717336a0 100644 --- a/test/mongo/pagination.spec.ts +++ b/test/mongo/pagination.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { setupCORSBypass } from "../CORSBypass"; -import { DataExplorer, QueryTab, TestAccount, CommandBarButton, Editor } from "../fx"; +import { CommandBarButton, DataExplorer, Editor, QueryTab, TestAccount } from "../fx"; import { serializeMongoToJson } from "../testData"; const databaseId = "test-e2etests-mongo-pagination"; diff --git a/test/sql/resourceToken.spec.ts b/test/sql/resourceToken.spec.ts index 5f8a700f8..157f773e9 100644 --- a/test/sql/resourceToken.spec.ts +++ b/test/sql/resourceToken.spec.ts @@ -11,10 +11,9 @@ import { resourceGroupName, subscriptionId, } from "../fx"; -import { getNoSqlRbacToken } from "../NoSqlTestSetup"; test("SQL account using Resource token", async ({ page }) => { - const nosqlAccountRbacToken = getNoSqlRbacToken() ?? ""; + const nosqlAccountRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN ?? ""; test.skip(nosqlAccountRbacToken.length > 0, "Resource tokens not supported when using data plane RBAC."); const credentials = getAzureCLICredentials(); diff --git a/test/testData.ts b/test/testData.ts index f9b493b70..e9ba759f1 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -18,7 +18,6 @@ import { subscriptionId, TestAccount, } from "./fx"; -import { getNoSqlRbacToken } from "./NoSqlTestSetup"; // In Node.js >= 19, globalThis.crypto is already available as a read-only getter. // Only assign the polyfill for older versions. @@ -135,7 +134,7 @@ async function createCosmosClientForSQLAccount( const rbacToken = accountType === TestAccount.SQL - ? getNoSqlRbacToken() + ? process.env.NOSQL_TESTACCOUNT_TOKEN : accountType === TestAccount.SQLContainerCopyOnly ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN : ""; diff --git a/test/testExplorer/TestExplorer.ts b/test/testExplorer/TestExplorer.ts index ce65d5f86..28f763a27 100644 --- a/test/testExplorer/TestExplorer.ts +++ b/test/testExplorer/TestExplorer.ts @@ -3,7 +3,6 @@ import "../../less/hostedexplorer.less"; import { DataExplorerInputsFrame } from "../../src/Contracts/ViewModels"; import { updateUserContext } from "../../src/UserContext"; import { get, listKeys } from "../../src/Utils/arm/generatedClients/cosmos/databaseAccounts"; -import { getNoSqlRbacToken } from "../NoSqlTestSetup"; const urlSearchParams = new URLSearchParams(window.location.search); const resourceGroup = urlSearchParams.get("resourceGroup") || process.env.RESOURCE_GROUP || ""; @@ -16,7 +15,7 @@ const enablecontainercopy = urlSearchParams.get("enablecontainercopy"); const nosqlRbacToken = urlSearchParams.get("nosqlRbacToken") || - (enablecontainercopy ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN : getNoSqlRbacToken()) || + (enablecontainercopy ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN : process.env.NOSQL_TESTACCOUNT_TOKEN) || ""; const nosqlReadOnlyRbacToken = @@ -30,6 +29,7 @@ const mongoRbacToken = urlSearchParams.get("mongoRbacToken") || process.env.MONG const mongo32RbacToken = urlSearchParams.get("mongo32RbacToken") || process.env.MONGO32_TESTACCOUNT_TOKEN || ""; const mongoReadOnlyRbacToken = urlSearchParams.get("mongoReadOnlyRbacToken") || process.env.MONGO_READONLY_TESTACCOUNT_TOKEN || ""; +const tenantId = urlSearchParams.get("tenantId") || process.env.AZURE_TENANT_ID || ""; const initTestExplorer = async (): Promise => { updateUserContext({ @@ -51,7 +51,7 @@ const initTestExplorer = async (): Promise => { case "gremlin": rbacToken = gremlinRbacToken; break; - case "tables": + case "table": rbacToken = tableRbacToken; break; case "cassandra": @@ -90,6 +90,7 @@ const initTestExplorer = async (): Promise => { resourceGroup, authorizationToken: `Bearer ${authToken}`, aadToken: rbacToken, + tenantId, features: {}, containerCopyEnabled: enablecontainercopy === "true", hasWriteAccess: true, diff --git a/utils/cleanupDBs.js b/utils/cleanupDBs.js index 0c19973a3..233743c0f 100644 --- a/utils/cleanupDBs.js +++ b/utils/cleanupDBs.js @@ -3,7 +3,7 @@ const { CosmosDBManagementClient } = require("@azure/arm-cosmosdb"); const ms = require("ms"); const subscriptionId = process.env["AZURE_SUBSCRIPTION_ID"]; -const resourceGroupName = "de-e2e-tests"; +const resourceGroupName = process.env["E2ETESTS_RESOURCEGROUP_NAME"]; const thirtyMinutesAgo = new Date(Date.now() - 1000 * 60 * 30).getTime();