diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 3b3ab5027..1eaa1661a 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -42,6 +42,11 @@ export interface DatabaseAccountExtendedProperties { publicNetworkAccess?: string; enablePriorityBasedExecution?: boolean; vcoreMongoEndpoint?: string; + virtualNetworkRules?: VNetRule[]; +} + +export interface VNetRule { + id: string; } export interface DatabaseAccountResponseLocation { diff --git a/src/Explorer/Tabs/CloudShellTab/Commands.tsx b/src/Explorer/Tabs/CloudShellTab/Commands.tsx index bd336143f..8eb8f913e 100644 --- a/src/Explorer/Tabs/CloudShellTab/Commands.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Commands.tsx @@ -70,7 +70,6 @@ export const commands = (terminalKind: TerminalKind, config?: CommandConfig): st "psql --version" ]; case TerminalKind.Mongo: - case TerminalKind.VCoreMongo: return [ // 1. Fetch and display location details in a readable format "curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'", @@ -89,7 +88,28 @@ export const commands = (terminalKind: TerminalKind, config?: CommandConfig): st // 8. Verify mongosh installation "mongosh --version", // 9. Login to MongoDB - `mongosh --host ${config.host} --port 10255 --username ${config.name} --password ${config.password} --ssl --sslAllowInvalidCertificates` + `mongosh --host ${config.host} --port 10255 --username ${config.name} --password ${config.password} --tls --tlsAllowInvalidCertificates` + ]; + case TerminalKind.VCoreMongo: + return [ + // 1. Fetch and display location details in a readable format + "curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'", + // 2. Check if mongosh is installed; if not, proceed with installation + "if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi", + // 3. Download mongosh if not installed + "if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz; fi", + // 4. Extract mongosh package if not installed + "if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-2.3.8-linux-x64.tgz; fi", + // 5. Move mongosh binaries if not installed + "if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/; fi", + // 6. Add mongosh to PATH if not installed + "if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi", + // 7. Source .bashrc to update PATH (even if mongosh was already installed) + "source ~/.bashrc", + // 8. Verify mongosh installation + "mongosh --version", + // 9. Login to MongoDBmongosh mongodb+srv://@neesharma-stage-mongo-vcore.mongocluster.cosmos.azure.com/?authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000\u0007 + `mongosh "mongodb+srv://nrj:@${config.endpoint}/?authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000" --tls --tlsAllowInvalidCertificates` ]; case TerminalKind.Cassandra: return [ diff --git a/src/Explorer/Tabs/CloudShellTab/Data.tsx b/src/Explorer/Tabs/CloudShellTab/Data.tsx index 9e9eb4105..639e484b6 100644 --- a/src/Explorer/Tabs/CloudShellTab/Data.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Data.tsx @@ -26,6 +26,15 @@ export const getUserRegion = async (subscriptionId: string, resourceGroup: strin }; +export async function getARMInfo(path: string, apiVersion: string = "2024-07-01"): Promise { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: path, + method: "GET", + apiVersion: apiVersion + }); +}; + export const deleteUserSettings = async (): Promise => { await armRequest({ host: configContext.ARM_ENDPOINT, @@ -43,26 +52,20 @@ export const getUserSettings = async (): Promise => { apiVersion: "2023-02-01-preview" }); - return { - location: resp?.properties?.preferredLocation, - sessionType: resp?.properties?.sessionType, - osType: resp?.properties?.preferredOsType - }; + console.log(resp); + return resp; }; -export const putEphemeralUserSettings = async (userSubscriptionId: string, userRegion: string) => { +export const putEphemeralUserSettings = async (userSubscriptionId: string, userRegion: string, vNetSettings?: object) => { const ephemeralSettings = { properties: { preferredOsType: OsType.Linux, preferredShellType: ShellType.Bash, preferredLocation: userRegion, - terminalSettings: { - fontSize: "Medium", - fontStyle: "monospace" - }, networkType: NetworkType.Default, sessionType: SessionType.Ephemeral, userSubscription: userSubscriptionId, + vnetSettings: vNetSettings ?? {} } }; diff --git a/src/Explorer/Tabs/CloudShellTab/DataModels.tsx b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx index de825a084..0f9b8795b 100644 --- a/src/Explorer/Tabs/CloudShellTab/DataModels.tsx +++ b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. */ + export const enum OsType { Linux = "linux", Windows = "windows" @@ -23,11 +24,25 @@ export const enum SessionType { } export type Settings = { - location: string; - sessionType: SessionType; - osType: OsType; + properties: UserSettingProperties }; +export type UserSettingProperties = { + networkType: string; + preferredLocation: string; + preferredOsType: OsType; + preferredShellType: ShellType; + userSubscription: string; + sessionType: SessionType; + vnetSettings: VnetSettings; +} + +export type VnetSettings = { + networkProfileResourceId: string; + relayNamespaceResourceId: string; + location: string; +} + export type ProvisionConsoleResponse = { properties: { osType: OsType; @@ -48,4 +63,86 @@ export type ConnectTerminalResponse = { tokenUpdated: boolean; }; +export type VnetModel = { + name: string; + id: string; + etag: string; + type: string; + location: string; + tags: Record; + properties: { + provisioningState: string; + resourceGuid: string; + addressSpace: { + addressPrefixes: string[]; + }; + encryption: { + enabled: boolean; + enforcement: string; + }; + privateEndpointVNetPolicies: string; + subnets: Array<{ + name: string; + id: string; + etag: string; + type: string; + properties: { + provisioningState: string; + addressPrefixes?: string[]; + addressPrefix?: string; + networkSecurityGroup?: { id: string }; + ipConfigurations?: { id: string }[]; + ipConfigurationProfiles?: { id: string }[]; + privateEndpoints?: { id: string }[]; + serviceEndpoints?: Array<{ + provisioningState: string; + service: string; + locations: string[]; + }>; + delegations?: Array<{ + name: string; + id: string; + etag: string; + type: string; + properties: { + provisioningState: string; + serviceName: string; + actions: string[]; + }; + }>; + purpose?: string; + privateEndpointNetworkPolicies?: string; + privateLinkServiceNetworkPolicies?: string; + }; + }>; + virtualNetworkPeerings: any[]; + enableDdosProtection: boolean; + }; +}; + +export type RelayNamespace = { + id: string; + name: string; + type: string; + location: string; + tags: Record; + properties: { + metricId: string; + serviceBusEndpoint: string; + provisioningState: string; + status: string; + createdAt: string; + updatedAt: string; + }; + sku: { + name: string; + tier: string; + }; +}; + +export type RelayNamespaceResponse = { + value: RelayNamespace[]; +}; + + diff --git a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx index 7ec2aefb0..9d939326e 100644 --- a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx +++ b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx @@ -2,13 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. */ +import { RelayNamespaceResponse, VnetModel } from "Explorer/Tabs/CloudShellTab/DataModels"; import { listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { Terminal } from "xterm"; import { TerminalKind } from "../../../Contracts/ViewModels"; import { userContext } from "../../../UserContext"; import { AttachAddon } from "./AttachAddOn"; import { getCommands } from "./Commands"; -import { authorizeSession, connectTerminal, deleteUserSettings, getNormalizedRegion, getUserSettings, provisionConsole, putEphemeralUserSettings, registerCloudShellProvider, validateUserSettings, verifyCloudShellProviderRegistration } from "./Data"; +import { authorizeSession, connectTerminal, getARMInfo, getNormalizedRegion, getUserSettings, provisionConsole, putEphemeralUserSettings, registerCloudShellProvider, verifyCloudShellProviderRegistration } from "./Data"; import { LogError, LogInfo } from "./LogFormatter"; export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind) => { @@ -24,59 +25,185 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter throw err; } - const region = userContext.databaseAccount?.location; - terminal.writeln(LogInfo(`Database Account Region identified as '${region}'`)); + const settings = await getUserSettings(); + let vNetSettings = {}; + if(settings?.properties?.vnetSettings && Object.keys(settings?.properties?.vnetSettings).length > 0) { - const defaultCloudShellRegion = "westus"; - const resolvedRegion = getNormalizedRegion(region, defaultCloudShellRegion); - - try { - var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(resolvedRegion, terminal); - } - catch (err) { - terminal.writeln(LogError(err)); - terminal.writeln(LogError(`Unable to provision console in request region, Falling back to default region i.e. ${defaultCloudShellRegion}`)); - var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(defaultCloudShellRegion, terminal); - } - - if(!socketUri) { - terminal.writeln(LogError('Unable to provision console. Close and Open the terminal again to retry.')); - return{}; - } + terminal.writeln(" Network Profile Resource ID: " + settings.properties.vnetSettings.networkProfileResourceId); + terminal.writeln(" Relay Namespace Resource ID: " + settings.properties.vnetSettings.relayNamespaceResourceId); - let socket = new WebSocket(socketUri); - - const dbName = userContext.databaseAccount.name; - let keys; - if (dbName) + vNetSettings = { + networkProfileResourceId: settings.properties.vnetSettings.networkProfileResourceId, + relayNamespaceResourceId: settings.properties.vnetSettings.relayNamespaceResourceId, + location: settings.properties.vnetSettings.location + }; + } + else { - keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); - } - - const initCommands = getCommands(shellType, keys?.primaryMasterKey); - socket = configureSocket(socket, socketUri, terminal, initCommands, 0); - - const attachAddon = new AttachAddon(socket); - terminal.loadAddon(attachAddon); - - // authorize the session - try { - const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri); - const cookieToken = authorizeResponse.token; - const a = document.createElement("img"); - a.src = targetUri + "&token=" + encodeURIComponent(cookieToken); - } catch (err) { - terminal.writeln(LogError('Unable to authorize the session')); - socket.close(); - throw err; + terminal.writeln("\x1B[1;31m No VNet settings found. Continuing with default settings\x1B[0m"); } - terminal.writeln(LogInfo("Connection Successful!!!")); + terminal.writeln("\x1B[1;37m Press '1' to continue with current or default setting.\x1B[0m"); + terminal.writeln("\x1B[1;37m Press '2' to configure new VNet to CloudShell.\x1B[0m"); + terminal.writeln("\x1B[1;37m Press '3' to Reset CloudShell VNet Settings.\x1B[0m"); + terminal.writeln("\x1B[1;37m Note: To learn how to configure VNet for CloudShell, go to this link https://aka.ms/cloudhellvnetsetup \x1B[0m"); + terminal.focus(); + + let isDefaultSetting = false; + const handleKeyPress = terminal.onKey(async ({ key }: { key: string }) => { - return socket; + if (key === "1") { + terminal.writeln("\x1B[1;32m Pressed 1, Continuing with current/default settings.\x1B[0m"); + + isDefaultSetting = true; + handleKeyPress.dispose(); + } + else if (key === "2") { + isDefaultSetting = false; + handleKeyPress.dispose(); + } + else if (key === "3") { + isDefaultSetting = true; + vNetSettings = {}; + handleKeyPress.dispose(); + } + else { + terminal.writeln("\x1B[1;31m Entered Wrong Input, only 1 or 2 are allowed. Exiting...\x1B[0m"); + setTimeout(() => terminal.dispose(), 2000); // Close terminal after 2 sec + handleKeyPress.dispose(); + return; + } + + if (!isDefaultSetting) { + terminal.writeln("\x1B[1;32m Pressed 2, Enter below details:\x1B[0m"); + const subscriptionId = await askQuestion(terminal, "Existing VNet Subscription ID"); + const resourceGroup = await askQuestion(terminal, "Existing VNet Resource Group"); + const vNetName = await askQuestion(terminal, "Existing VNet Name"); + + const vNetConfig = await getARMInfo(`/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/virtualNetworks/${vNetName}`); + + terminal.writeln("Suggested Network Profiles:"); + const ipConfigurationProfiles = vNetConfig.properties.subnets.reduce<{ id: string }[]>( + (acc, subnet) => acc.concat(subnet.properties.ipConfigurationProfiles || []), + [] + ); + for (let i = 0; i < ipConfigurationProfiles.length; i++) { + const match = ipConfigurationProfiles[i].id.match(/\/networkProfiles\/([^/]+)/); + const result = match ? `/${match[1]}` : null; + terminal.writeln(result); + } + + const networkProfile = await askQuestion(terminal, "Associated Network Profile"); + + const relays = await getARMInfo( + `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Relay/namespaces`, + "2024-01-01"); + terminal.writeln("Suggested Network Relays:"); + for (let i = 0; i < relays.value.length; i++) { + terminal.writeln(relays.value[i].name); + } + + const relayName = await askQuestion(terminal, "Network Relay"); + + const networkProfileResourceId = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfile}`; + const relayResourceId = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}`; + + // const vNetRules = userContext.databaseAccount.properties?.virtualNetworkRules; + // if(vNetRules && vNetRules.length > 0) { + // for (let i = 0; i < vNetRules.length; i++) { + // const vNetName = vNetRules[i].id; + + // terminal.writeln(vNetName); + // } + // } + + vNetSettings = { + networkProfileResourceId: networkProfileResourceId, + relayNamespaceResourceId: relayResourceId, + location: userContext.databaseAccount.location + }; + } + + const region = userContext.databaseAccount?.location; + terminal.writeln(LogInfo(` Database Account Region identified as '${region}'`)); + + const defaultCloudShellRegion = "westus"; + const resolvedRegion = getNormalizedRegion(region, defaultCloudShellRegion); + + try { + var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(resolvedRegion, terminal, vNetSettings); + } + catch (err) { + terminal.writeln(LogError(err)); + terminal.writeln(LogError(`Unable to provision console in request region, Falling back to default region i.e. ${defaultCloudShellRegion}`)); + var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(defaultCloudShellRegion, terminal, vNetSettings); + } + + if(!socketUri) { + terminal.writeln(LogError('Unable to provision console. Close and Open the terminal again to retry.')); + return{}; + } + + let socket = new WebSocket(socketUri); + + const dbName = userContext.databaseAccount.name; + let keys; + if (dbName) + { + keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); + } + + const initCommands = getCommands(shellType, keys?.primaryMasterKey); + socket = configureSocket(socket, socketUri, terminal, initCommands, 0); + + const attachAddon = new AttachAddon(socket); + terminal.loadAddon(attachAddon); + + // authorize the session + try { + const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri); + const cookieToken = authorizeResponse.token; + const a = document.createElement("img"); + a.src = targetUri + "&token=" + encodeURIComponent(cookieToken); + } catch (err) { + terminal.writeln(LogError('Unable to authorize the session')); + socket.close(); + throw err; + } + + terminal.writeln(LogInfo("Connection Successful!!!")); + terminal.focus(); + + return socket; + }); } + +const askQuestion = (terminal: any, question: string) => { + return new Promise((resolve) => { + terminal.write(`\x1B[1;34m${question}: \x1B[0m`); + terminal.focus(); + let response = ""; + + const dataListener = terminal.onData((data: string) => { + if (data === "\r") { // Enter key pressed + terminal.writeln(""); // Move to a new line + dataListener.dispose(); + return resolve(response.trim()); + } else if (data === "\u007F" || data === "\b") { // Handle backspace + if (response.length > 0) { + response = response.slice(0, -1); + terminal.write("\b \b"); // Move cursor back, clear character + } + } else { + response += data; + terminal.write(data); // Display the typed or pasted characters + } + }); + }); + }; + let keepAliveID: NodeJS.Timeout = null; let pingCount = 0; @@ -168,12 +295,13 @@ const sendStartupCommands = (socket: WebSocket, initCommands: string) => { const provisionCloudShellSession = async( resolvedRegion: string, - terminal: Terminal + terminal: Terminal, + vNetSettings: object ): Promise<{ socketUri?: string; provisionConsoleResponse?: any; targetUri?: string }> => { return new Promise((resolve, reject) => { // Show consent message inside the terminal - terminal.writeln(`\x1B[1;33m⚠️ Are you agreeing to continue with CloudShell terminal at ${resolvedRegion}.\x1B[0m`); - terminal.writeln("\x1B[1;37mPress 'Y' to continue or 'N' to exit.\x1B[0m"); + terminal.writeln(`\x1B[1;33m ⚠️ Are you agreeing to continue with CloudShell terminal at ${resolvedRegion}.\x1B[0m`); + terminal.writeln("\x1B[1;37m Press 'Y' to continue or 'N' to exit.\x1B[0m"); terminal.focus(); // Listen for user input @@ -183,28 +311,14 @@ const provisionCloudShellSession = async( if (key.toLowerCase() === "y") { terminal.writeln("\x1B[1;32mConsent given. Requesting CloudShell. !\x1B[0m"); - terminal.writeln(LogInfo('Resetting user settings...')); - await deleteUserSettings(); terminal.writeln(LogInfo('Applying fresh user settings...')); try { - await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion); + await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion, vNetSettings); } catch (err) { terminal.writeln(LogError('Unable to update user settings to ephemeral session.')); return reject(err); } - - // verify user settings after they have been updated to ephemeral - try { - const userSettings = await getUserSettings(); - const isValidUserSettings = validateUserSettings(userSettings); - if (!isValidUserSettings) { - throw new Error("Invalid user settings detected for ephemeral session."); - } - } catch (err) { - terminal.writeln(LogError('Unable to verify user settings for ephemeral session.')); - return reject(err); - } - + // trigger callback to provision console internal let provisionConsoleResponse; try {