From e3b77ec1f20ed5ae0016bab718b752edb0c5323c Mon Sep 17 00:00:00 2001 From: jawelton74 <103591340+jawelton74@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:00:29 -0700 Subject: [PATCH 1/5] Make playwright test environment more dynamic (#2532) * Update Az CLI to use new secrets. * Add test step for Az login * Remove test step. Fix up account names in test code. * Fix the account prefix env. variable. * Fix it in the CI config as well. * Try to fix the CI config for some tests * Clean up access tokens for NoSQL. * Disable most tests while sorting out account setup. Add debug tracing. * Comment out most tests. * Set RG Name var in CI config. * Fix up tenant id. * Enable other API tests * e-enable more tests. * Re-enable remaining tests. * Remove all of the test.skip() calls. * Remove debug traces. * Remove function to retrieve SQL RBAC token, this is handled by the CI config now. * Fix rbac token detection for table and resource token tests. * Minor cleanup and fixing the cleanup config. * Preview site and storage changes. Cleanup script changes. * Clean up before PR. * Fix resource group name in test results email. --- .github/workflows/ci.yml | 146 ++++++++---------------------- .github/workflows/cleanup.yml | 10 +- preview/index.js | 2 +- test/NoSqlTestSetup.ts | 71 --------------- test/fx.ts | 33 ++++--- test/mongo/pagination.spec.ts | 2 +- test/sql/resourceToken.spec.ts | 3 +- test/testData.ts | 3 +- test/testExplorer/TestExplorer.ts | 7 +- utils/cleanupDBs.js | 2 +- 10 files changed, 74 insertions(+), 205 deletions(-) delete mode 100644 test/NoSqlTestSetup.ts 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..b2d3471af 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -20,16 +20,18 @@ 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 }} steps: - uses: actions/checkout@v4 + env: + E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} - 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/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(); From acd2e3d83980e96cb20e15a8d2199b9e3bacaf74 Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Mon, 20 Jul 2026 09:32:56 -0700 Subject: [PATCH 2/5] Enable throughput switch for GSI source containers (#2535) Co-authored-by: Asier Isayas --- src/Explorer/Controls/Settings/SettingsComponent.tsx | 12 ++++++------ .../ContainerPolicyComponent.tsx | 1 - .../SettingsSubComponents/ScaleComponent.test.tsx | 2 +- .../SettingsSubComponents/ScaleComponent.tsx | 4 ++-- .../ThroughputInputAutoPilotV3Component.test.tsx | 2 +- .../ThroughputInputAutoPilotV3Component.tsx | 4 ++-- .../__snapshots__/SettingsComponent.test.tsx.snap | 3 +-- .../Controls/ThroughputInput/ThroughputInput.tsx | 6 +++--- .../VectorEmbeddingPoliciesComponent.tsx | 6 +++--- .../AddGlobalSecondaryIndexPanel.tsx | 8 ++++---- .../Components/ThroughputComponent.tsx | 2 +- .../Components/VectorSearchComponent.tsx | 6 +++--- 12 files changed, 27 insertions(+), 29 deletions(-) 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) { @@ -327,7 +327,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/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} /> From 5b3e066b234725458e1b32f8df03195fc61fe079 Mon Sep 17 00:00:00 2001 From: jawelton74 <103591340+jawelton74@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:32:20 -0700 Subject: [PATCH 3/5] Fix the test cleanup automation to work with recent test changes (#2538) * Move the environment variable declaration. * Move the env var declaration again --- .github/workflows/cleanup.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index b2d3471af..b045f3bd3 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -21,11 +21,9 @@ jobs: runs-on: ubuntu-latest env: AZURE_SUBSCRIPTION_ID: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} + E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} steps: - uses: actions/checkout@v4 - env: - E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} - - name: "Az CLI login" uses: azure/login@v2 with: From 43fe8a002b6693a8f38a6f8eaa358aa0fac9db35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 4 Aug 2026 10:04:27 +0200 Subject: [PATCH 4/5] feat: Add Cosmos DB Shell (NoSQL) support to Cloud Shell (#2549) * Add Cosmos DB Shell (NoSQL) support to Cloud Shell Adds Cosmos DB (NoSQL) support to the Cloud Shell experience and wires up authentication for RBAC / local-auth-disabled accounts: - Enable the Open Cosmos DB Shell button behind the enableCloudShell feature flag. - Bootstrap .NET SDK 10 in Cloud Shell before installing the CosmosDBShell tool. - Force gateway connection mode and add --verbose to surface connection errors. - Select Entra ID vs account-key auth via isCloudShellEntraAuthEnabled (covers dataplane RBAC and disableLocalAuth accounts). - Acquire a data-plane-scoped Entra token on demand when no cached aadToken exists, and omit --connect-tenant so a missing token falls through to DefaultAzureCredential (Cloud Shell az session) instead of interactive/device-code auth. * Fix popup_window_error in CosmosDB Cloud Shell Entra auth Acquiring an Entra token on demand via acquireMsalTokenForAccount triggered a browser loginPopup (even in silent mode when no MSAL account was cached), which fails with popup_window_error inside the hosted Cloud Shell context. getKey now returns the cached userContext.aadToken when present, or an empty string otherwise. With no token exported, the CosmosDBShell tool falls through to DefaultAzureCredential, which uses the Cloud Shell's signed-in az session. * Force --connect-azure-cli whenever no credential env var is exported In Azure Cloud Shell, DefaultAzureCredential tries ManagedIdentityCredential first, which cannot mint a token for the *.documents.azure.com audience (AudienceNotSupported). Force AzureCliCredential via --connect-azure-cli whenever no key/token env var is exported, covering both the Entra-no-token and key-auth-empty-key cases. Also auto-update the CosmosDBShell tool so cached installs pick up the new flag. * Silently mint a Cosmos data-plane token for the Cloud Shell Entra path A disableLocalAuth Cosmos account whose Data Explorer session is still in key mode has no cached userContext.aadToken, and the ephemeral Cloud Shell can obtain a Cosmos token from neither its managed identity (AudienceNotSupported for *.documents.azure.com) nor its az session (not logged in). Mint a Cosmos-scoped token in the browser and export it via COSMOSDB_SHELL_TOKEN. The acquisition is guarded on an existing cached MSAL account so it can never trigger an interactive popup, and any failure returns an empty string so the tool falls back to --connect-azure-cli. * Only accept Y/N at the Cloud Shell consent prompt askConfirmation now ignores any key that is not Y or N instead of treating every non-Y key as a decline, so an accidental keypress no longer aborts the Cloud Shell consent flow. Also add diagnostic warnings on the Cloud Shell token path so the browser console reveals whether the silent Cosmos token mint was skipped (no cached MSAL account) or returned empty. * Use device-code auth when Cloud Shell has no token * Pass the Data Explorer credential to the Cosmos DB shell instead of signing in interactively Azure 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 neither the interactive browser nor the device-code flow is usable from the embedded terminal. Resolve the credential in Data Explorer and hand it to the shell out-of-band: - getCosmosDBShellCredential() returns an Entra ID data-plane token (cached aadToken, or one minted silently only when an MSAL account already exists so it can never trigger a popup), falling back to the account master key unless local auth is disabled. It reports which kind it resolved so the correct env var is exported. - CosmosDBShellHandler exports COSMOSDB_SHELL_TOKEN or COSMOSDB_SHELL_ACCOUNT_KEY and drops every credential flag, so the tool always lands on a terminal, non-interactive step of its credential chain. - When nothing can be resolved, print actionable guidance instead of launching the tool with no credential. * Fix TS7011 in ShellTypeFactory tests by typing the mocked getAllAccounts The empty array literal in the getMsalInstance mock had no contextual type, so tsc inferred an implicit any[] return under noImplicitAny. Route every mock through a typed mockMsalAccounts helper. * Reuse DE's cached credential for Cosmos DB Shell; add read-only key fallback * Deliver Cosmos DB Shell key as a full connection string; harden key resolution * Surface the specific reason a Cosmos DB Shell credential could not be resolved * Deliver Cosmos DB Shell credential inline (export+connect on one line), mirroring Mongo handler * Log which Cosmos DB Shell credential kind was resolved for debugging * Increase ARM timeout for Cloud Shell provisioning calls to avoid spurious abort errors * Gate Cosmos DB Shell button behind enableCosmosDBShell feature flag * Fix TS2741: add missing enableCosmosDBShell to Features test fixture --- src/Contracts/ViewModels.ts | 1 + src/Explorer/Explorer.tsx | 4 + .../CommandBarComponentButtonFactory.tsx | 19 +- .../Data/CloudShellClient.test.tsx | 6 + .../CloudShellTab/Data/CloudShellClient.tsx | 10 + .../ShellTypes/CosmosDBShellHandler.test.tsx | 122 +++++++++ .../ShellTypes/CosmosDBShellHandler.tsx | 129 ++++++++++ .../ShellTypes/ShellTypeFactory.test.tsx | 223 +++++++++++++++- .../ShellTypes/ShellTypeFactory.tsx | 241 +++++++++++++++++- .../CloudShellTab/Utils/CommonUtils.test.tsx | 67 +++++ .../Tabs/CloudShellTab/Utils/CommonUtils.tsx | 10 +- src/Platform/Hosted/extractFeatures.ts | 2 + src/Utils/AuthorizationUtils.test.ts | 1 + src/Utils/AuthorizationUtils.ts | 18 ++ 14 files changed, 835 insertions(+), 18 deletions(-) create mode 100644 src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.test.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.test.tsx 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/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/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/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 + ); +} From 37ef0d2dab61eee9ec581d210b50810b21223d19 Mon Sep 17 00:00:00 2001 From: vchske Date: Fri, 7 Aug 2026 12:56:14 -0700 Subject: [PATCH 5/5] Update vector embed policy remove preview and make spherical default (#2555) --- .../VectorSearch/VectorEmbeddingPoliciesComponent.tsx | 4 ++-- src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts | 3 +-- src/Localization/en/Resources.json | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx index cce26189d..0fbb151da 100644 --- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx @@ -166,7 +166,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent 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/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",