From a7e38201c46756c2d2f3548e7b8de478a261857e Mon Sep 17 00:00:00 2001 From: Sourabh Jain Date: Mon, 7 Apr 2025 21:26:02 +0530 Subject: [PATCH] refactored code --- .../CloudShellTab/CloudShellTabComponent.tsx | 25 +- .../Core/CloudShellTerminalCore.tsx | 393 +++++ .../Data/CloudShellApiClient.tsx | 263 ++++ .../CloudShellTab/Data/LocalizationUtils.tsx | 12 + .../Tabs/CloudShellTab/Data/RegionUtils.tsx | 37 + .../Tabs/CloudShellTab/Models/ApiVersions.ts | 74 + .../Tabs/CloudShellTab/Models/DataModels.tsx | 163 +++ .../CloudShellTab/Network/FirewallHandler.tsx | 94 ++ .../Network/NetworkAccessHandler.tsx | 99 ++ .../CloudShellTab/Network/VNetHandler.tsx | 894 ++++++++++++ .../ShellTypes/CassandraShellHandler.tsx | 80 + .../ShellTypes/MongoShellHandler.tsx | 77 + .../ShellTypes/PostgresShellHandler.tsx | 82 ++ .../ShellTypes/ShellTypeFactory.tsx | 57 + .../ShellTypes/VCoreMongoShellHandler.tsx | 78 + .../Tabs/CloudShellTab/UseTerminal.tsx | 1291 ----------------- .../Tabs/CloudShellTab/Utils/AttachAddOn.tsx | 123 ++ .../Tabs/CloudShellTab/Utils/CommonUtils.tsx | 84 ++ .../Tabs/CloudShellTab/Utils/LogFormatter.tsx | 28 + .../Tabs/Shared/CheckFirewallRules.ts | 2 +- 20 files changed, 2653 insertions(+), 1303 deletions(-) create mode 100644 src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts create mode 100644 src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Network/NetworkAccessHandler.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.tsx delete mode 100644 src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx create mode 100644 src/Explorer/Tabs/CloudShellTab/Utils/LogFormatter.tsx diff --git a/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx b/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx index 5bdbb449c..38ee3401f 100644 --- a/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx +++ b/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx @@ -3,8 +3,7 @@ import { Terminal } from "xterm"; import { FitAddon } from 'xterm-addon-fit'; import "xterm/css/xterm.css"; import { TerminalKind } from "../../../Contracts/ViewModels"; -import { startCloudShellTerminal } from "./UseTerminal"; - +import { startCloudShellTerminal } from "./Core/CloudShellTerminalCore"; export interface CloudShellTerminalProps { shellType: TerminalKind; @@ -49,17 +48,21 @@ export const CloudShellTerminalComponent: React.FC = ({ const handleResize = () => fitAddon.fit(); window.addEventListener('resize', handleResize); - socketRef.current = startCloudShellTerminal(term, shellType); - - term.onData((data) => { - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - socketRef.current.send(data); - } - }); + try { + socketRef.current = startCloudShellTerminal(term, shellType); + term.onData((data) => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.send(data); + } + }); + } catch (error) { + console.error("Failed to initialize CloudShell terminal:", error); + term.writeln(`\x1B[31mError initializing terminal: ${error.message}\x1B[0m`); + } // Cleanup function to close WebSocket and dispose terminal return () => { - if (!socketRef.current) return; // Prevent errors if WebSocket is not initialized + if (!socketRef.current) return; if (socketRef.current) { socketRef.current.close(); // Close WebSocket connection } @@ -67,7 +70,7 @@ export const CloudShellTerminalComponent: React.FC = ({ term.dispose(); // Clean up XTerm instance }; - }, []); + }, [shellType]); return
; }; diff --git a/src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx b/src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx new file mode 100644 index 000000000..7e2837755 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx @@ -0,0 +1,393 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Core functionality for CloudShell terminal management + */ + +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { userContext } from "../../../../UserContext"; +import { + authorizeSession, + connectTerminal, + provisionConsole, + putEphemeralUserSettings, + registerCloudShellProvider, + verifyCloudShellProviderRegistration +} from "../Data/CloudShellApiClient"; +import { getNormalizedRegion } from "../Data/RegionUtils"; +import { ShellTypeHandler } from "../ShellTypes/ShellTypeFactory"; +import { AttachAddon } from "../Utils/AttachAddOn"; +import { wait } from "../Utils/CommonUtils"; +import { terminalLog } from "../Utils/LogFormatter"; + +// Constants +const DEFAULT_CLOUDSHELL_REGION = "westus"; +const POLLING_INTERVAL_MS = 5000; +const MAX_RETRY_COUNT = 10; +const MAX_PING_COUNT = 20 * 60; // 20 minutes (60 seconds/minute) + +/** + * Main function to start a CloudShell terminal + */ +export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind) => { + // Get the shell handler for this type + const shellHandler = ShellTypeHandler.getHandler(shellType); + + terminal.writeln(terminalLog.header("Initializing Azure CloudShell")); + await ensureCloudShellProviderRegistered(terminal); + + const { resolvedRegion, defaultCloudShellRegion } = determineCloudShellRegion(terminal); + + // Ask for user consent for region + const consentGranted = await askForRegionConsent(terminal, resolvedRegion); + if (!consentGranted) { + return {}; // Exit if user declined + } + + // Check network requirements for this shell type + const networkConfig = await shellHandler.configureNetworkAccess(terminal, resolvedRegion); + + terminal.writeln(""); + // Provision CloudShell session + terminal.writeln(terminalLog.cloudshell(`Provisioning Started....`)); + + let sessionDetails: { + socketUri?: string; + provisionConsoleResponse?: any; + targetUri?: string; + }; + + try { + sessionDetails = await provisionCloudShellSession(resolvedRegion, terminal, networkConfig.vNetSettings, networkConfig.isAllPublicAccessEnabled); + } catch (err) { + terminal.writeln(terminalLog.error(err)); + terminal.writeln(terminalLog.error("Failed to provision in primary region")); + terminal.writeln(terminalLog.warning(`Attempting with fallback region: ${defaultCloudShellRegion}`)); + + sessionDetails = await provisionCloudShellSession(defaultCloudShellRegion, terminal, networkConfig.vNetSettings, networkConfig.isAllPublicAccessEnabled); + } + + if (!sessionDetails.socketUri) { + terminal.writeln(terminalLog.error('Unable to provision console. Please try again later.')); + return {}; + } + + // Configure WebSocket connection with shell-specific commands + const socket = await establishTerminalConnection( + terminal, + shellHandler, + sessionDetails.socketUri, + sessionDetails.provisionConsoleResponse, + sessionDetails.targetUri + ); + + return socket; +}; + +/** + * Ensures that the CloudShell provider is registered for the current subscription + */ +export const ensureCloudShellProviderRegistered = async (terminal: Terminal): Promise => { + try { + terminal.writeln(terminalLog.info("Verifying provider registration...")); + const response: any = await verifyCloudShellProviderRegistration(userContext.subscriptionId); + + if (response.registrationState !== "Registered") { + terminal.writeln(terminalLog.warning("Registering CloudShell provider...")); + await registerCloudShellProvider(userContext.subscriptionId); + terminal.writeln(terminalLog.success("Provider registration successful")); + } + } catch (err) { + terminal.writeln(terminalLog.error("Unable to verify provider registration")); + throw err; + } +}; + +/** + * Determines the appropriate CloudShell region + */ +export const determineCloudShellRegion = (terminal: Terminal): { resolvedRegion: string; defaultCloudShellRegion: string } => { + const region = userContext.databaseAccount?.location; + const resolvedRegion = getNormalizedRegion(region, DEFAULT_CLOUDSHELL_REGION); + + return { resolvedRegion, defaultCloudShellRegion: DEFAULT_CLOUDSHELL_REGION }; +}; + +/** + * Asks the user for consent to use the specified CloudShell region + */ +export const askForRegionConsent = async (terminal: Terminal, resolvedRegion: string): Promise => { + terminal.writeln(terminalLog.header("CloudShell Region Confirmation")); + terminal.writeln(terminalLog.info("The CloudShell container will be provisioned in a specific Azure region.")); + // Data residency and compliance information + terminal.writeln(terminalLog.subheader("Important Information")); + const dbRegion = userContext.databaseAccount?.location || "unknown"; + terminal.writeln(terminalLog.item("Database Region", dbRegion)); + terminal.writeln(terminalLog.item("CloudShell Container Region", resolvedRegion)); + + terminal.writeln(terminalLog.subheader("What this means to you?")); + terminal.writeln(terminalLog.item("Data Residency", "Commands and query results will be processed in this region")); + terminal.writeln(terminalLog.item("Network", "Database connections will originate from this region")); + + // Consent question + terminal.writeln(""); + terminal.writeln(terminalLog.prompt("Would you like to provision Azure CloudShell in the '" + resolvedRegion + "' region?")); + terminal.writeln(terminalLog.prompt("Press 'Y' to continue or 'N' to cancel (Y/N)")); + + return new Promise((resolve) => { + const keyListener = terminal.onKey(({ key }: { key: string }) => { + keyListener.dispose(); + terminal.writeln(""); + + if (key.toLowerCase() === 'y') { + terminal.writeln(terminalLog.success("Proceeding with CloudShell in " + resolvedRegion)); + terminal.writeln(terminalLog.separator()); + resolve(true); + } else { + terminal.writeln(terminalLog.error("CloudShell provisioning canceled")); + setTimeout(() => terminal.dispose(), 2000); + resolve(false); + } + }); + }); +}; + +/** + * Provisions a CloudShell session + */ +export const provisionCloudShellSession = async ( + resolvedRegion: string, + terminal: Terminal, + vNetSettings: object, + isAllPublicAccessEnabled: boolean +): Promise<{ socketUri?: string; provisionConsoleResponse?: any; targetUri?: string }> => { + return new Promise( async (resolve, reject) => { + try { + terminal.writeln(terminalLog.header("Configuring CloudShell Session")); + // Check if vNetSettings is available and not empty + const hasVNetSettings = vNetSettings && Object.keys(vNetSettings).length > 0; + if (hasVNetSettings) { + terminal.writeln(terminalLog.vnet("Enabling private network configuration")); + displayNetworkSettings(terminal, vNetSettings, resolvedRegion); + } + else { + terminal.writeln(terminalLog.warning("No VNet configuration provided")); + terminal.writeln(terminalLog.warning("CloudShell will be provisioned with public network access")); + + if (!isAllPublicAccessEnabled) { + terminal.writeln(terminalLog.error("Warning: Your database has network restrictions")); + terminal.writeln(terminalLog.error("CloudShell may not be able to connect without proper VNet configuration")); + } + } + terminal.writeln(terminalLog.warning("Any previous VNet settings will be overridden")); + + // Apply user settings + await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion, vNetSettings); + terminal.writeln(terminalLog.success("Session settings applied")); + + // Provision console + let provisionConsoleResponse; + let attemptCounter = 0; + + do { + provisionConsoleResponse = await provisionConsole(userContext.subscriptionId, resolvedRegion); + terminal.writeln(terminalLog.progress("Provisioning", provisionConsoleResponse.properties.provisioningState)); + + attemptCounter++; + + if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { + await wait(POLLING_INTERVAL_MS); + } + } while (provisionConsoleResponse.properties.provisioningState !== "Succeeded" && attemptCounter < 10); + + if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { + const errorMessage = `Provisioning failed: ${provisionConsoleResponse.properties.provisioningState}`; + terminal.writeln(terminalLog.error(errorMessage)); + return reject(new Error(errorMessage)); + } + + // Connect terminal + const connectTerminalResponse = await connectTerminal( + provisionConsoleResponse.properties.uri, + { rows: terminal.rows, cols: terminal.cols } + ); + + const targetUri = `${provisionConsoleResponse.properties.uri}/terminals?cols=${terminal.cols}&rows=${terminal.rows}&version=2019-01-01&shell=bash`; + const termId = connectTerminalResponse.id; + + // Determine socket URI + let socketUri = connectTerminalResponse.socketUri.replace(":443/", ""); + const targetUriBody = targetUri.replace('https://', '').split('?')[0]; + + if (socketUri.indexOf(targetUriBody) === -1) { + socketUri = `wss://${targetUriBody}/${termId}`; + } + + if (targetUriBody.includes('servicebus')) { + const targetUriBodyArr = targetUriBody.split('/'); + socketUri = `wss://${targetUriBodyArr[0]}/$hc/${targetUriBodyArr[1]}/terminals/${termId}`; + } + + return resolve({ socketUri, provisionConsoleResponse, targetUri }); + } catch (err) { + terminal.writeln(terminalLog.error(`Provisioning failed: ${err.message}`)); + return reject(err); + } + }); +}; + +/** + * Display VNet settings in the terminal + */ +const displayNetworkSettings = (terminal: Terminal, vNetSettings: any, resolvedRegion: string): void => { + if (vNetSettings.networkProfileResourceId) { + const profileName = vNetSettings.networkProfileResourceId.split('/').pop(); + terminal.writeln(terminalLog.item("Network Profile", profileName)); + + if (vNetSettings.relayNamespaceResourceId) { + const relayName = vNetSettings.relayNamespaceResourceId.split('/').pop(); + terminal.writeln(terminalLog.item("Relay Namespace", relayName)); + } + + terminal.writeln(terminalLog.item("Region", resolvedRegion)); + terminal.writeln(terminalLog.success("CloudShell will use this VNet to connect to your database")); + } +}; + +/** + * Establishes a terminal connection via WebSocket + */ +export const establishTerminalConnection = async ( + terminal: Terminal, + shellHandler: any, + socketUri: string, + provisionConsoleResponse: any, + targetUri: string +): Promise => { + let socket = new WebSocket(socketUri); + + // Get shell-specific initial commands + const initCommands = await shellHandler.getInitialCommands(); + + // Configure the socket + socket = configureSocketConnection(socket, socketUri, terminal, initCommands, 0); + + // Attach the terminal addon + const attachAddon = new AttachAddon(socket); + terminal.loadAddon(attachAddon); + terminal.writeln(terminalLog.success("Connection established")); + + // Authorize the session + try { + const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri); + const cookieToken = authorizeResponse.token; + + // Load auth token with a hidden image + const img = document.createElement("img"); + img.src = `${targetUri}&token=${encodeURIComponent(cookieToken)}`; + terminal.focus(); + } catch (err) { + terminal.writeln(terminalLog.error("Authorization failed")); + socket.close(); + throw err; + } + + return socket; +}; + +/** + * Configures a WebSocket connection for the terminal + */ +export const configureSocketConnection = ( + socket: WebSocket, + uri: string, + terminal: Terminal, + initCommands: string, + socketRetryCount: number +): WebSocket => { + let jsonData = ''; + let keepAliveID: NodeJS.Timeout = null; + let pingCount = 0; + + sendTerminalStartupCommands(socket, initCommands); + + socket.onclose = () => { + if (keepAliveID) { + clearTimeout(keepAliveID); + pingCount = 0; + } + terminal.writeln(terminalLog.warning("Session terminated. Refresh the page to start a new session.")); + }; + + socket.onerror = () => { + if (socketRetryCount < MAX_RETRY_COUNT && socket.readyState !== WebSocket.CLOSED) { + configureSocketConnection(socket, uri, terminal, initCommands, socketRetryCount + 1); + } else { + socket.close(); + } + }; + + socket.onmessage = (event: MessageEvent) => { + pingCount = 0; // Reset ping count on message receipt + + let eventData = ''; + if (typeof event.data === "object") { + try { + const enc = new TextDecoder("utf-8"); + eventData = enc.decode(event.data as any); + } catch (e) { + // Not an array buffer, ignore + } + } + + if (typeof event.data === 'string') { + eventData = event.data; + } + + // Process event data + if (eventData.includes("ie_us") && eventData.includes("ie_ue")) { + const statusData = eventData.split('ie_us')[1].split('ie_ue')[0]; + console.log(statusData); + } else if (eventData.includes("ie_us")) { + jsonData += eventData.split('ie_us')[1]; + } else if (eventData.includes("ie_ue")) { + jsonData += eventData.split('ie_ue')[0]; + console.log(jsonData); + jsonData = ''; + } else if (jsonData.length > 0) { + jsonData += eventData; + } + }; + + return socket; +}; + +/** + * Sends startup commands to the terminal + */ +export const sendTerminalStartupCommands = (socket: WebSocket, initCommands: string): void => { + let keepAliveID: NodeJS.Timeout = null; + let pingCount = 0; + + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(initCommands); + } else { + socket.onopen = () => { + socket.send(initCommands); + + const keepSocketAlive = (socket: WebSocket) => { + if (socket.readyState === WebSocket.OPEN) { + if (pingCount >= MAX_PING_COUNT) { + socket.close(); + } else { + socket.send(''); + pingCount++; + keepAliveID = setTimeout(() => keepSocketAlive(socket), 1000); + } + } + }; + + keepSocketAlive(socket); + }; + } +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx new file mode 100644 index 000000000..0718495d5 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx @@ -0,0 +1,263 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * CloudShell API client for various operations + */ + +import { v4 as uuidv4 } from 'uuid'; +import { configContext } from "../../../../ConfigContext"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { userContext } from '../../../../UserContext'; +import { armRequest } from "../../../../Utils/arm/request"; +import { ApiVersionsConfig, DEFAULT_API_VERSIONS } from "../Models/ApiVersions"; +import { Authorization, ConnectTerminalResponse, NetworkType, OsType, ProvisionConsoleResponse, ResourceType, SessionType, Settings, ShellType } from "../Models/DataModels"; +import { getLocale } from '../Data/LocalizationUtils'; + +// Current shell type context +let currentShellType: TerminalKind | null = null; + +/** + * Set the active shell type to determine API version + */ +export const setShellType = (shellType: TerminalKind): void => { + currentShellType = shellType; +}; + +/** + * Get the appropriate API version based on shell type and resource type + */ +export const getApiVersion = (resourceType?: ResourceType, apiVersions?: ApiVersionsConfig): string => { + if (!apiVersions) { + apiVersions = DEFAULT_API_VERSIONS; // Default fallback + } + + // Shell type is set, try to get specific version in this priority: + // 1. Shell-specific + resource-specific + if (resourceType && + apiVersions.SHELL_TYPES[currentShellType]) { + const shellTypeConfig = apiVersions.SHELL_TYPES[currentShellType]; + if (resourceType in shellTypeConfig) { + return shellTypeConfig[resourceType] as string; + } + } + + // 2. Resource-specific default + if (resourceType && resourceType in apiVersions.RESOURCE_DEFAULTS) { + return apiVersions.RESOURCE_DEFAULTS[resourceType]; + } + + // 3. Global default + return apiVersions.DEFAULT; +}; + +export const getUserRegion = async (subscriptionId: string, resourceGroup: string, accountName: string) => { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`, + method: "GET", + apiVersion: "2022-12-01" + }); +}; + +export const deleteUserSettings = async (): Promise => { + await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/providers/Microsoft.Portal/userSettings/cloudconsole`, + method: "DELETE", + apiVersion: "2023-02-01-preview" + }); +}; + +export const getUserSettings = async (): Promise => { + const resp = await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/providers/Microsoft.Portal/userSettings/cloudconsole`, + method: "GET", + apiVersion: "2023-02-01-preview" + }); + return resp; +}; + +export const putEphemeralUserSettings = async (userSubscriptionId: string, userRegion: string, vNetSettings?: object) => { + const ephemeralSettings = { + properties: { + preferredOsType: OsType.Linux, + preferredShellType: ShellType.Bash, + preferredLocation: userRegion, + networkType: (!vNetSettings || Object.keys(vNetSettings).length === 0) ? NetworkType.Default : (vNetSettings ? NetworkType.Isolated : NetworkType.Default), + sessionType: SessionType.Ephemeral, + userSubscription: userSubscriptionId, + vnetSettings: vNetSettings ?? {} + } + }; + + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/providers/Microsoft.Portal/userSettings/cloudconsole`, + method: "PUT", + apiVersion: "2023-02-01-preview", + body: ephemeralSettings + }); +}; + +export const verifyCloudShellProviderRegistration = async(subscriptionId: string) => { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell`, + method: "GET", + apiVersion: "2022-12-01" + }); +}; + +export const registerCloudShellProvider = async (subscriptionId: string) => { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell/register`, + method: "POST", + apiVersion: "2022-12-01" + }); +}; + +export const provisionConsole = async (subscriptionId: string, location: string): Promise => { + const data = { + properties: { + osType: OsType.Linux + } + }; + + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `providers/Microsoft.Portal/consoles/default`, + method: "PUT", + apiVersion: "2023-02-01-preview", + customHeaders: { + 'x-ms-console-preferred-location': location + }, + body: data, + }); +}; + +export const connectTerminal = async (consoleUri: string, size: { rows: number, cols: number }): Promise => { + const targetUri = consoleUri + `/terminals?cols=${size.cols}&rows=${size.rows}&version=2019-01-01&shell=bash`; + const resp = await fetch(targetUri, { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': '2', + 'Authorization': userContext.authorizationToken, + 'x-ms-client-request-id': uuidv4(), + 'Accept-Language': getLocale(), + }, + body: "{}" // empty body is necessary + }); + return resp.json(); +}; + +export const authorizeSession = async (consoleUri: string): Promise => { + const targetUri = consoleUri + "/authorize"; + const resp = await fetch(targetUri, { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Authorization': userContext.authorizationToken, + 'Accept-Language': getLocale(), + "Content-Type": 'application/json' + }, + body: "{}" // empty body is necessary + }); + return resp.json(); +}; + +export async function getNetworkProfileInfo(networkProfileResourceId: string, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK); + return await GetARMCall(networkProfileResourceId, apiVersion); +} + +export async function getAccountDetails(databaseAccountId: string, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE); + return await GetARMCall(databaseAccountId, apiVersion); +} + +export async function getVnetInformation(vnetId: string, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET); + return await GetARMCall(vnetId, apiVersion); +} + +export async function getSubnetInformation(subnetId: string, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET); + return await GetARMCall(subnetId, apiVersion); +} + +export async function updateSubnetInformation(subnetId: string, request: object, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET); + return await PutARMCall(subnetId, request, apiVersion); +} + +export async function updateDatabaseAccount(accountId: string, request: object, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE); + return await PutARMCall(accountId, request, apiVersion); +} + +export async function getDatabaseOperations(accountId: string, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE); + return await GetARMCall(`${accountId}/operations`, apiVersion); +} + +export async function updateVnet(vnetId: string, request: object, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET); + return await PutARMCall(vnetId, request, apiVersion); +} + +export async function getVnet(vnetId: string, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET); + return await GetARMCall(vnetId, apiVersion); +} + +export async function createNetworkProfile(networkProfileId: string, request: object, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK); + return await PutARMCall(networkProfileId, request, apiVersion); +} + +export async function createRelay(relayId: string, request: object, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY); + return await PutARMCall(relayId, request, apiVersion); +} + +export async function getRelay(relayId: string, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY); + return await GetARMCall(relayId, apiVersion); +} + +export async function createRoleOnNetworkProfile(roleId: string, request: object, apiVersionOverride?: string): Promise { +const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE); + return await PutARMCall(roleId, request, apiVersion); +} + +export async function createRoleOnRelay(roleId: string, request: object, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE); + return await PutARMCall(roleId, request, apiVersion); +} + +export async function createPrivateEndpoint(privateEndpointId: string, request: object, apiVersionOverride?: string): Promise { + const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK); + return await PutARMCall(privateEndpointId, request, apiVersion); +} + +export async function GetARMCall(path: string, apiVersion: string = '2024-07-01'): Promise { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: path, + method: "GET", + apiVersion: apiVersion + }); +} + +export async function PutARMCall(path: string, request: object, apiVersion: string = '2024-07-01'): Promise { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: path, + method: "PUT", + apiVersion: apiVersion, + body: request + }); +} diff --git a/src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx b/src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx new file mode 100644 index 000000000..e1e499ea5 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx @@ -0,0 +1,12 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Localization utilities for CloudShell + */ + +/** + * Gets the current locale for API requests + */ +export const getLocale = (): string => { + const langLocale = navigator.language; + return (langLocale && langLocale.length > 2 ? langLocale : 'en-us'); +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx b/src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx new file mode 100644 index 000000000..c3db566e0 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Region utilities for CloudShell + */ + +export const getLocale = () => { + const langLocale = navigator.language; + return (langLocale && langLocale.length === 2 ? langLocale[1] : 'en-us'); +}; + +const validCloudShellRegions = new Set([ + "westus", + "southcentralus", + "eastus", + "northeurope", + "westeurope", + "centralindia", + "southeastasia", + "westcentralus" +]); + +/** + * Normalizes a region name to a valid CloudShell region + * @param region The region to normalize + * @param defaultCloudshellRegion Default region to use if the provided region is not supported + */ +export const getNormalizedRegion = (region: string, defaultCloudshellRegion: string) => { + if (!region) return defaultCloudshellRegion; + + const regionMap: Record = { + "centralus": "westcentralus", + "eastus2": "eastus" + }; + + const normalizedRegion = regionMap[region.toLowerCase()] || region; + return validCloudShellRegions.has(normalizedRegion.toLowerCase()) ? normalizedRegion : defaultCloudshellRegion; +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts b/src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts new file mode 100644 index 000000000..f4478ae2c --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * API versions configuration for CloudShell + */ + +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { ResourceType } from "./DataModels"; + +/** + * Configuration for API versions used by the CloudShell + */ +export type ApiVersionsConfig = { + DEFAULT: string; + RESOURCE_DEFAULTS: Record; + SHELL_TYPES: Record>; +} + +/** + * Default API versions configuration + */ +export const DEFAULT_API_VERSIONS: ApiVersionsConfig = { + DEFAULT: '2024-07-01', + RESOURCE_DEFAULTS: { + [ResourceType.DATABASE]: '2024-11-15', + [ResourceType.NETWORK]: '2024-07-01', + [ResourceType.VNET]: '2024-07-01', + [ResourceType.SUBNET]: '2024-07-01', + [ResourceType.RELAY]: '2022-10-01', + [ResourceType.ROLE]: '2022-04-01', + }, + SHELL_TYPES: { + [TerminalKind.Mongo]: { + [ResourceType.DATABASE]: '2024-11-15', + [ResourceType.NETWORK]: '2024-07-01', + [ResourceType.VNET]: '2024-07-01', + [ResourceType.SUBNET]: '2024-07-01', + [ResourceType.RELAY]: '2024-01-01', + [ResourceType.ROLE]: '2022-04-01', + }, + [TerminalKind.VCoreMongo]: { + [ResourceType.DATABASE]: '2024-07-01', + [ResourceType.NETWORK]: '2024-07-01', + [ResourceType.VNET]: '2024-07-01', + [ResourceType.SUBNET]: '2024-07-01', + [ResourceType.RELAY]: '2024-01-01', + [ResourceType.ROLE]: '2022-04-01', + }, + [TerminalKind.Postgres]: { + [ResourceType.DATABASE]: '2024-11-15', + [ResourceType.NETWORK]: '2024-07-01', + [ResourceType.VNET]: '2024-07-01', + [ResourceType.SUBNET]: '2024-07-01', + [ResourceType.RELAY]: '2024-01-01', + [ResourceType.ROLE]: '2022-04-01', + }, + [TerminalKind.Cassandra]: { + [ResourceType.DATABASE]: '2024-11-15', + [ResourceType.NETWORK]: '2024-07-01', + [ResourceType.VNET]: '2024-07-01', + [ResourceType.SUBNET]: '2024-07-01', + [ResourceType.RELAY]: '2024-01-01', + [ResourceType.ROLE]: '2022-04-01', + }, + [TerminalKind.Default]: { + [ResourceType.DATABASE]: undefined, + [ResourceType.NETWORK]: undefined, + [ResourceType.VNET]: undefined, + [ResourceType.SUBNET]: undefined, + [ResourceType.RELAY]: undefined, + [ResourceType.ROLE]: undefined, + }, + }, + }; + diff --git a/src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx b/src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx new file mode 100644 index 000000000..a5f8c42ca --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx @@ -0,0 +1,163 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Data models for CloudShell + */ + +export const enum OsType { + Linux = "linux", + Windows = "windows" +} + +export const enum ShellType { + Bash = "bash", + PowerShellCore = "pwsh" +} + +export const enum NetworkType { + Default = "Default", + Isolated = "Isolated" +} + +export const enum SessionType { + Mounted = "Mounted", + Ephemeral = "Ephemeral" +} + +export const enum UserInputs { + NoReset = "1", + ConfigureVNet = "2", + ResetVNet = "3" +}; + +export type Settings = { + 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; + provisioningState: string; + uri: string; + }; +}; + +export type Authorization = { + token: string; +}; + +export type ConnectTerminalResponse = { + id: string; + idleTimeout: string; + rootDirectory: string; + socketUri: string; + 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[]; +}; + +/** + * Resource types for API versioning + */ +export enum ResourceType { + NETWORK = "NETWORK", + DATABASE = "DATABASE", + VNET = "VNET", + SUBNET = "SUBNET", + RELAY = "RELAY", + ROLE = "ROLE" +} diff --git a/src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx b/src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx new file mode 100644 index 000000000..b4d433843 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx @@ -0,0 +1,94 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Firewall handling functionality for CloudShell + */ + +import { Terminal } from "xterm"; +import { userContext } from "../../../../UserContext"; +import { hasFirewallRestrictions } from "../../Shared/CheckFirewallRules"; +import { getAccountDetails, updateDatabaseAccount } from "../Data/CloudShellApiClient"; +import { askConfirmation } from "../Utils/CommonUtils"; +import { terminalLog } from "../Utils/LogFormatter"; + +export class FirewallHandler { + /** + * Checks if firewall configuration is needed for CloudShell + */ + public static async checkFirewallConfiguration(terminal: Terminal): Promise { + if (!hasFirewallRestrictions()) { + return false; // No firewall rules to configure + } + + terminal.writeln(terminalLog.header("Database Firewall Configuration")); + terminal.writeln(terminalLog.warning("Your database has firewall restrictions enabled")); + terminal.writeln(terminalLog.warning("CloudShell might need access through these restrictions")); + + const shouldConfigureFirewall = await askConfirmation( + terminal, + "Would you like to check and configure firewall settings?" + ); + + if (!shouldConfigureFirewall) { + terminal.writeln(terminalLog.info("Skipping firewall configuration")); + return false; + } + + return await this.configureFirewallForCloudShell(terminal); + } + + /** + * Configures firewall for CloudShell access + */ + private static async configureFirewallForCloudShell(terminal: Terminal): Promise { + try { + // Get current database account details + terminal.writeln(terminalLog.database("Retrieving current firewall configuration...")); + const dbAccount = userContext.databaseAccount; + const currentDbAccount = await getAccountDetails(dbAccount.id); + + // Check if "Allow Azure Services" is already enabled + const ipRules = currentDbAccount.properties.ipRules || []; + const azureServicesEnabled = currentDbAccount.properties.publicNetworkAccess === "Enabled"; + + if (azureServicesEnabled) { + terminal.writeln(terminalLog.success("Azure services access is already enabled")); + return true; + } + + // Ask user to enable Azure services access + terminal.writeln(terminalLog.warning("Azure services access is not enabled")); + terminal.writeln(terminalLog.info("CloudShell requires 'Allow Azure Services' to be enabled")); + + const enableAzureServices = await askConfirmation( + terminal, + "Enable 'Allow Azure Services' for this database?" + ); + + if (!enableAzureServices) { + terminal.writeln(terminalLog.warning("CloudShell may not be able to connect without enabling Azure services access")); + return false; + } + + // Update database account to enable Azure services access + terminal.writeln(terminalLog.info("Updating database firewall configuration...")); + + // Create update payload - only modify firewall-related properties + const updatePayload = { + ...currentDbAccount, + properties: { + ...currentDbAccount.properties, + publicNetworkAccess: "Enabled" + } + }; + + await updateDatabaseAccount(dbAccount.id, updatePayload); + terminal.writeln(terminalLog.success("Database firewall updated successfully")); + terminal.writeln(terminalLog.success("Azure services access is now enabled")); + + return true; + } catch (error) { + terminal.writeln(terminalLog.error(`Error configuring firewall: ${error.message}`)); + return false; + } + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/Network/NetworkAccessHandler.tsx b/src/Explorer/Tabs/CloudShellTab/Network/NetworkAccessHandler.tsx new file mode 100644 index 000000000..f9e7b1981 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Network/NetworkAccessHandler.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Network access configuration handler for CloudShell + */ + +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { IsPublicAccessAvailable } from "../../Shared/CheckFirewallRules"; +import { getUserSettings } from "../Data/CloudShellApiClient"; +import { VnetSettings } from "../Models/DataModels"; +import { terminalLog } from "../Utils/LogFormatter"; +import { VNetHandler } from "./VNetHandler"; + +export class NetworkAccessHandler { + /** + * Configures network access for the CloudShell based on shell type and network restrictions + */ + public static async configureNetworkAccess( + terminal: Terminal, + region: string, + shellType: TerminalKind + ): Promise<{ + vNetSettings: any; + isAllPublicAccessEnabled: boolean; + }> { + // Check if public access is available for this shell type + const isAllPublicAccessEnabled = await IsPublicAccessAvailable(shellType); + + // If public access is enabled, no need for VNet configuration + if (isAllPublicAccessEnabled) { + terminal.writeln(terminalLog.database("Public access enabled. Skipping VNet configuration.")); + return { + vNetSettings: {}, + isAllPublicAccessEnabled: true + }; + } + + // Public access is restricted, we need to configure a VNet or use existing one + terminal.writeln(terminalLog.database("Network restrictions detected")); + terminal.writeln(terminalLog.info("Loading CloudShell configuration...")); + + // Get existing settings if available + const settings = await getUserSettings(); + if (!settings) { + terminal.writeln(terminalLog.warning("No existing user settings found.")); + } + + // Retrieve CloudShell VNet settings if available + let cloudShellVnetSettings: VnetSettings | undefined; + if (settings) { + cloudShellVnetSettings = await VNetHandler.retrieveCloudShellVnetSettings(settings, terminal); + } + + // If CloudShell has VNet settings, check with database config + let finalVNetSettings = {}; + if (cloudShellVnetSettings && cloudShellVnetSettings.networkProfileResourceId) { + // Check if we should use existing VNet settings + const isContinueWithSameVnet = await VNetHandler.askForVNetConfigConsent(terminal, shellType); + + if (isContinueWithSameVnet) { + // Check if the VNet is already configured in the database + const isVNetInDatabaseConfig = await VNetHandler.isCloudShellVNetInDatabaseConfig(cloudShellVnetSettings, terminal); + + if (!isVNetInDatabaseConfig) { + terminal.writeln(terminalLog.warning("CloudShell VNet is not configured in database access list")); + const addToDatabase = await VNetHandler.askToAddVNetToDatabase(terminal, cloudShellVnetSettings); + + if (addToDatabase) { + await VNetHandler.addCloudShellVNetToDatabase(cloudShellVnetSettings, terminal); + finalVNetSettings = cloudShellVnetSettings; + } else { + // User declined to add VNet to database, need to recreate + terminal.writeln(terminalLog.warning("Will configure new VNet...")); + cloudShellVnetSettings = undefined; + } + } else { + terminal.writeln(terminalLog.success("CloudShell VNet is already in database configuration")); + finalVNetSettings = cloudShellVnetSettings; + } + } else { + cloudShellVnetSettings = undefined; // User declined to use existing VNet settings + } + } + + // If we don't have valid VNet settings, create new ones + if (!cloudShellVnetSettings || !cloudShellVnetSettings.networkProfileResourceId) { + terminal.writeln(terminalLog.subheader("Configuring network infrastructure")); + finalVNetSettings = await VNetHandler.configureCloudShellVNet(terminal, region); + + // Add the new VNet to the database + await VNetHandler.addCloudShellVNetToDatabase(finalVNetSettings as VnetSettings, terminal); + } + + return { + vNetSettings: finalVNetSettings, + isAllPublicAccessEnabled: false + }; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx b/src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx new file mode 100644 index 000000000..2f1f38a31 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx @@ -0,0 +1,894 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * VNet handling functionality for CloudShell + */ + +import { v4 as uuidv4 } from 'uuid'; +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { userContext } from "../../../../UserContext"; +import { hasPrivateEndpointsRestrictions } from "../../Shared/CheckFirewallRules"; +import { + createNetworkProfile, + createPrivateEndpoint, + createRelay, + createRoleOnNetworkProfile, + createRoleOnRelay, + getAccountDetails, + getDatabaseOperations, + getNetworkProfileInfo, + getRelay, + getSubnetInformation, + getVnet, + getVnetInformation, + updateDatabaseAccount, + updateSubnetInformation, + updateVnet +} from "../Data/CloudShellApiClient"; +import { Settings, VnetSettings } from "../Models/DataModels"; +import { askConfirmation, askQuestion, wait } from "../Utils/CommonUtils"; +import { terminalLog } from "../Utils/LogFormatter"; + +// Constants for VNet configuration +const POLLING_INTERVAL_MS = 5000; +const MAX_RETRY_COUNT = 10; +const STANDARD_SKU = "Standard"; +const DEFAULT_VNET_ADDRESS_PREFIX = "10.0.0.0/16"; +const DEFAULT_SUBNET_ADDRESS_PREFIX = "10.0.1.0/24"; +const DEFAULT_CONTAINER_INSTANCE_OID = "88536fb9-d60a-4aee-8195-041425d6e927"; + +export class VNetHandler { + /** + * Retrieves CloudShell VNet settings from user settings + */ + public static async retrieveCloudShellVnetSettings(settings: Settings, terminal: Terminal): Promise { + if (settings?.properties?.vnetSettings && Object.keys(settings.properties.vnetSettings).length > 0) { + try { + const netProfileInfo = await getNetworkProfileInfo(settings.properties.vnetSettings.networkProfileResourceId); + + terminal.writeln(terminalLog.header("Existing Network Configuration")); + + const subnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0] + .properties.ipConfigurations[0].properties.subnet.id; + const vnetResourceId = subnetId.replace(/\/subnets\/[^/]+$/, ''); + + terminal.writeln(terminalLog.item("VNet", vnetResourceId)); + terminal.writeln(terminalLog.item("Subnet", subnetId)); + terminal.writeln(terminalLog.item("Location", settings.properties.vnetSettings.location)); + terminal.writeln(terminalLog.item("Network Profile", settings.properties.vnetSettings.networkProfileResourceId)); + terminal.writeln(terminalLog.item("Relay Namespace", settings.properties.vnetSettings.relayNamespaceResourceId)); + + return { + networkProfileResourceId: settings.properties.vnetSettings.networkProfileResourceId, + relayNamespaceResourceId: settings.properties.vnetSettings.relayNamespaceResourceId, + location: settings.properties.vnetSettings.location + }; + } catch (err) { + terminal.writeln(terminalLog.warning("Error retrieving network profile. Will configure new network.")); + return undefined; + } + } + + return undefined; + } + + /** + * Asks the user if they want to use existing network configuration (VNet or private endpoint) + */ + public static async askForVNetConfigConsent(terminal: Terminal, shellType: TerminalKind = null): Promise { + // Check if this shell type supports only private endpoints + const isPrivateEndpointOnlyShell = shellType === TerminalKind.VCoreMongo; + // Check if the database has private endpoints configured + const hasPrivateEndpoints = hasPrivateEndpointsRestrictions(); + + // Determine which network type to mention based on shell type and database configuration + const networkType = isPrivateEndpointOnlyShell || hasPrivateEndpoints ? "private endpoint" : "network"; + + // Ask for consent + terminal.writeln(""); + terminal.writeln(terminalLog.prompt(`Use this existing ${networkType} configuration?`)); + terminal.writeln(terminalLog.info(`Answering 'N' will configure a new ${networkType} for CloudShell`)); + + return await askConfirmation(terminal, `Press Y/N to continue...`); + } + + /** + * Checks if the CloudShell VNet is already in the database configuration + */ + public static async isCloudShellVNetInDatabaseConfig(vNetSettings: VnetSettings, terminal: Terminal): Promise { + try { + terminal.writeln(terminalLog.subheader("Verifying if CloudShell VNet is configured in database")); + + // Get the subnet ID from the CloudShell Network Profile + const netProfileInfo = await getNetworkProfileInfo(vNetSettings.networkProfileResourceId); + + if (!netProfileInfo?.properties?.containerNetworkInterfaceConfigurations?.[0] + ?.properties?.ipConfigurations?.[0]?.properties?.subnet?.id) { + terminal.writeln(terminalLog.warning("Could not retrieve subnet ID from CloudShell VNet")); + return false; + } + + const cloudShellSubnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0] + .properties.ipConfigurations[0].properties.subnet.id; + + terminal.writeln(terminalLog.item("CloudShell Subnet", cloudShellSubnetId.split('/').pop() || "")); + + // Check if this subnet ID is in the database VNet rules + const dbAccount = userContext.databaseAccount; + if (!dbAccount?.properties?.virtualNetworkRules) { + return false; + } + + const vnetRules = dbAccount.properties.virtualNetworkRules; + + // Check if the CloudShell subnet is already in the rules + return vnetRules.some(rule => rule.id === cloudShellSubnetId); + + } catch (err) { + terminal.writeln(terminalLog.error("Error checking database VNet configuration")); + return false; + } + } + + /** + * Asks the user if they want to add the CloudShell VNet to the database configuration + */ + public static async askToAddVNetToDatabase(terminal: Terminal, vNetSettings: VnetSettings): Promise { + terminal.writeln(""); + terminal.writeln(terminalLog.header("Network Configuration Mismatch")); + terminal.writeln(terminalLog.warning("Your CloudShell VNet is not in your database's allowed networks")); + terminal.writeln(terminalLog.warning("To connect from CloudShell, this VNet must be added to your database")); + + return await askConfirmation(terminal, "Add CloudShell VNet to database configuration?"); + } + + /** + * Adds the CloudShell VNet to the database configuration + * Now supports both VNet rules and private endpoints + */ +public static async addCloudShellVNetToDatabase(vNetSettings: VnetSettings, terminal: Terminal): Promise { + try { + terminal.writeln(terminalLog.header("Updating database network configuration")); + + // Step 1: Get the subnet ID from CloudShell Network Profile + const { cloudShellSubnetId, cloudShellVnetId } = await this.getCloudShellNetworkIds(vNetSettings, terminal); + + // Step 2: Get current database account details + const { currentDbAccount } = await this.getDatabaseAccountDetails(terminal); + + // Step 3: Determine if database uses private endpoints + const usesPrivateEndpoints = hasPrivateEndpointsRestrictions() || + (currentDbAccount.properties.privateEndpointConnections?.length > 0); + + // Log which networking mode we're using + if (usesPrivateEndpoints) { + terminal.writeln(terminalLog.info("Database is configured with private endpoints")); + } else { + terminal.writeln(terminalLog.info("Database is configured with VNet rules")); + } + + // Step 4: Check if connection is already configured + if (usesPrivateEndpoints) { + if (await this.isPrivateEndpointAlreadyConfigured(cloudShellVnetId, currentDbAccount, terminal)) { + return; + } + } else { + if (await this.isVNetAlreadyConfigured(cloudShellSubnetId, currentDbAccount, terminal)) { + return; + } + } + + // Step 5: Check network resource statuses and ongoing operations + const { vnetInfo, subnetInfo, operationInProgress } = + await this.checkNetworkResourceStatuses(cloudShellSubnetId, cloudShellVnetId, currentDbAccount.id, terminal); + + // Step 6: If no operation in progress, update the configuration + if (!operationInProgress) { + if (usesPrivateEndpoints) { + // Create or update private endpoint configuration + await this.configurePrivateEndpoint( + cloudShellSubnetId, + vnetInfo.location, + currentDbAccount.id, + terminal + ); + } else { + // Enable CosmosDB service endpoint on subnet if needed (for VNet rules) + await this.enableCosmosDBServiceEndpoint(cloudShellSubnetId, subnetInfo, terminal); + + // Update database account with VNet rule + await this.updateDatabaseWithVNetRule(currentDbAccount, cloudShellSubnetId, currentDbAccount.id, terminal); + } + } else { + terminal.writeln(terminalLog.info("Monitoring existing network operation...")); + // Step 7: Monitor the update progress + await this.monitorVNetAdditionProgress(cloudShellSubnetId, currentDbAccount.id, terminal); + } + + } catch (err) { + terminal.writeln(terminalLog.error(`Error updating database network configuration: ${err.message}`)); + throw err; + } +} + +/** + * Checks if a private endpoint is already configured for the CloudShell VNet + */ +private static async isPrivateEndpointAlreadyConfigured( + cloudShellVnetId: string, + currentDbAccount: any, + terminal: Terminal +): Promise { + // Check if private endpoints exist and are properly configured for this VNet + const hasConfiguredEndpoint = currentDbAccount.properties.privateEndpointConnections?.some( + (connection: any) => { + const isApproved = connection.properties.privateLinkServiceConnectionState.status === 'Approved'; + // We would need to check if the endpoint is in the CloudShell VNet + // For simplicity, we're assuming connection.properties.networkInterface contains this info + const endpointVNetId = connection.properties.networkInterface?.id?.split('/subnets/')[0]; + return isApproved && endpointVNetId === cloudShellVnetId; + } + ); + + if (hasConfiguredEndpoint) { + terminal.writeln(terminalLog.success("CloudShell private endpoint is already configured")); + return true; + } + + return false; +} + +/** + * Configures a private endpoint for the CloudShell VNet to connect to the database + */ +private static async configurePrivateEndpoint( + cloudShellSubnetId: string, + vnetLocation: any, + dbAccountId: string, + terminal: Terminal +): Promise { + // Extract necessary information from IDs + const subnetIdParts = cloudShellSubnetId.split('/'); + const subnetIndex = subnetIdParts.indexOf('subnets'); + + const subnetName = subnetIdParts[subnetIndex + 1]; + const resourceGroup = subnetIdParts[4]; + const subscriptionId = subnetIdParts[2]; + + // Generate a unique name for the private endpoint + const privateEndpointName = `pe-cloudshell-cosmos-${Math.floor(10000 + Math.random() * 90000)}`; + + terminal.writeln(terminalLog.subheader("Creating private endpoint for CloudShell")); + terminal.writeln(terminalLog.item("Private Endpoint Name", privateEndpointName)); + terminal.writeln(terminalLog.item("Target Subnet", subnetName)); + + // Construct the private endpoint creation payload + const privateEndpointPayload = { + location: vnetLocation, + properties: { + privateLinkServiceConnections: [ + { + name: privateEndpointName, + properties: { + privateLinkServiceId: dbAccountId, + groupIds: [ + "MongoDB" + ], + requestMessage: "CloudShell connectivity request" + }, + type: "Microsoft.Network/privateEndpoints/privateLinkServiceConnections" + } + ], + subnet: { + id: cloudShellSubnetId + } + } + }; + + // Send the request to create the private endpoint + // Note: This is a placeholder - we would need to implement this API call + terminal.writeln(terminalLog.info("Submitting private endpoint creation request")); + + try { + const privateEndpointUrl = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/privateEndpoints/${privateEndpointName}`; + + await createPrivateEndpoint(privateEndpointUrl, privateEndpointPayload, "2024-05-01"); + + terminal.writeln(terminalLog.success("Private endpoint creation request submitted")); + terminal.writeln(terminalLog.warning("Please approve the private endpoint connection in the Azure portal")); + terminal.writeln(terminalLog.info("Note: Private endpoint operations may take several minutes to complete")); + } catch (err) { + terminal.writeln(terminalLog.error(`Failed to create private endpoint: ${err.message}`)); + throw err; + } +} + /** + * Gets the subnet and VNet IDs from CloudShell Network Profile + */ + private static async getCloudShellNetworkIds(vNetSettings: VnetSettings, terminal: Terminal): Promise<{ cloudShellSubnetId: string; cloudShellVnetId: string }> { + const netProfileInfo = await getNetworkProfileInfo(vNetSettings.networkProfileResourceId); + + if (!netProfileInfo?.properties?.containerNetworkInterfaceConfigurations?.[0] + ?.properties?.ipConfigurations?.[0]?.properties?.subnet?.id) { + throw new Error("Could not retrieve subnet ID from CloudShell VNet"); + } + + const cloudShellSubnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0] + .properties.ipConfigurations[0].properties.subnet.id; + + // Extract VNet ID from subnet ID + const cloudShellVnetId = cloudShellSubnetId.substring(0, cloudShellSubnetId.indexOf('/subnets/')); + + terminal.writeln(terminalLog.subheader("Identified CloudShell network resources")); + terminal.writeln(terminalLog.item("Subnet", cloudShellSubnetId.split('/').pop() || "")); + terminal.writeln(terminalLog.item("VNet", cloudShellVnetId.split('/').pop() || "")); + + return { cloudShellSubnetId, cloudShellVnetId }; + } + + /** + * Gets the database account details + */ + private static async getDatabaseAccountDetails(terminal: Terminal): Promise<{ currentDbAccount: any }> { + const dbAccount = userContext.databaseAccount; + terminal.writeln(terminalLog.database("Verifying current configuration")); + const currentDbAccount = await getAccountDetails(dbAccount.id); + + return { currentDbAccount }; + } + + /** + * Checks if the VNet is already configured in the database + */ + private static async isVNetAlreadyConfigured(cloudShellSubnetId: string, currentDbAccount: any, terminal: Terminal): Promise { + const vnetAlreadyConfigured = currentDbAccount.properties.virtualNetworkRules && + currentDbAccount.properties.virtualNetworkRules.some( + (rule: any) => rule.id === cloudShellSubnetId + ); + + if (vnetAlreadyConfigured) { + terminal.writeln(terminalLog.success("CloudShell VNet is already in database configuration")); + return true; + } + + return false; + } + + /** + * Checks the status of network resources and ongoing operations + */ + private static async checkNetworkResourceStatuses( + cloudShellSubnetId: string, + cloudShellVnetId: string, + dbAccountId: string, + terminal: Terminal + ): Promise<{ vnetInfo: any; subnetInfo: any; operationInProgress: boolean }> { + terminal.writeln(terminalLog.subheader("Checking network resource status")); + + let operationInProgress = false; + let vnetInfo: any = null; + let subnetInfo: any = null; + + if (cloudShellVnetId && cloudShellSubnetId) { + // Get VNet and subnet resource status + vnetInfo = await getVnetInformation(cloudShellVnetId); + subnetInfo = await getSubnetInformation(cloudShellSubnetId); + + // Check if there's an ongoing operation on the VNet or subnet + const vnetProvisioningState = vnetInfo?.properties?.provisioningState; + const subnetProvisioningState = subnetInfo?.properties?.provisioningState; + + if (vnetProvisioningState !== 'Succeeded' && vnetProvisioningState !== 'Failed') { + terminal.writeln(terminalLog.warning(`VNet operation in progress: ${vnetProvisioningState}`)); + operationInProgress = true; + } + + if (subnetProvisioningState !== 'Succeeded' && subnetProvisioningState !== 'Failed') { + terminal.writeln(terminalLog.warning(`Subnet operation in progress: ${subnetProvisioningState}`)); + operationInProgress = true; + } + + // Also check database operations + const latestDbAccount = await getAccountDetails(dbAccountId); + + if (latestDbAccount.properties.virtualNetworkRules) { + const isPendingAdd = latestDbAccount.properties.virtualNetworkRules.some( + (rule: any) => rule.id === cloudShellSubnetId && rule.status === 'Updating' + ); + + if (isPendingAdd) { + terminal.writeln(terminalLog.warning("CloudShell VNet addition to database is already in progress")); + operationInProgress = true; + } + } + } + + return { vnetInfo, subnetInfo, operationInProgress }; + } + + /** + * Enables the CosmosDB service endpoint on a subnet if needed + */ + private static async enableCosmosDBServiceEndpoint(cloudShellSubnetId: string, subnetInfo: any, terminal: Terminal): Promise { + if (!subnetInfo) { + terminal.writeln(terminalLog.warning("Unable to check subnet endpoint configuration")); + return; + } + + terminal.writeln(terminalLog.subheader("Checking and configuring CosmosDB service endpoint")); + + // Parse the subnet ID to get resource information + const subnetIdParts = cloudShellSubnetId.split('/'); + const subnetIndex = subnetIdParts.indexOf('subnets'); + if (subnetIndex > 0) { + const subnetName = subnetIdParts[subnetIndex + 1]; + const vnetName = subnetIdParts[subnetIndex - 1]; + const resourceGroup = subnetIdParts[4]; + const subscriptionId = subnetIdParts[2]; + + // Get the subnet URL + const subnetUrl = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}/subnets/${subnetName}`; + + // Check if CosmosDB service endpoint is already enabled + const hasCosmosDBEndpoint = subnetInfo.properties.serviceEndpoints && + subnetInfo.properties.serviceEndpoints.some( + (endpoint: any) => endpoint.service === 'Microsoft.AzureCosmosDB' + ); + + if (!hasCosmosDBEndpoint) { + terminal.writeln(terminalLog.warning("Enabling CosmosDB service endpoint on subnet...")); + + // Create update payload with CosmosDB service endpoint + const serviceEndpoints = [ + ...(subnetInfo.properties.serviceEndpoints || []), + { service: 'Microsoft.AzureCosmosDB' } + ]; + + // Update the subnet configuration while preserving existing properties + const subnetUpdatePayload = { + ...subnetInfo, + properties: { + ...subnetInfo.properties, + serviceEndpoints: serviceEndpoints + } + }; + + // Apply the subnet update + await updateSubnetInformation(subnetUrl, subnetUpdatePayload); + + // Wait for the subnet update to complete + let subnetUpdateComplete = false; + let subnetRetryCount = 0; + + while (!subnetUpdateComplete && subnetRetryCount < MAX_RETRY_COUNT) { + const updatedSubnet = await getSubnetInformation(subnetUrl); + + const endpointEnabled = updatedSubnet.properties.serviceEndpoints && + updatedSubnet.properties.serviceEndpoints.some( + (endpoint: any) => endpoint.service === 'Microsoft.AzureCosmosDB' + ); + + if (endpointEnabled && updatedSubnet.properties.provisioningState === 'Succeeded') { + subnetUpdateComplete = true; + terminal.writeln(terminalLog.success("CosmosDB service endpoint enabled successfully")); + } else { + subnetRetryCount++; + terminal.writeln(terminalLog.progress("Subnet update", `Waiting (${subnetRetryCount}/${MAX_RETRY_COUNT})`)); + await wait(POLLING_INTERVAL_MS); + } + } + + if (!subnetUpdateComplete) { + throw new Error("Failed to enable CosmosDB service endpoint on subnet"); + } + } else { + terminal.writeln(terminalLog.success("CosmosDB service endpoint is already enabled")); + } + } + } + + /** + * Updates the database account with a new VNet rule + */ + private static async updateDatabaseWithVNetRule(currentDbAccount: any, cloudShellSubnetId: string, dbAccountId: string, terminal: Terminal): Promise { + // Create a deep copy of the current database account + const updatePayload = JSON.parse(JSON.stringify(currentDbAccount)); + + // Update only the network-related properties + updatePayload.properties.virtualNetworkRules = [ + ...(currentDbAccount.properties.virtualNetworkRules || []), + { id: cloudShellSubnetId, ignoreMissingVNetServiceEndpoint: false } + ]; + updatePayload.properties.isVirtualNetworkFilterEnabled = true; + + // Update the database account + terminal.writeln(terminalLog.subheader("Submitting VNet update request to database")); + await updateDatabaseAccount(dbAccountId, updatePayload); + terminal.writeln(terminalLog.success("Updated Database account with Cloud Shell Vnet")); + } + + /** + * Monitors the progress of adding a VNet to the database account + */ + private static async monitorVNetAdditionProgress(cloudShellSubnetId: string, dbAccountId: string, terminal: Terminal): Promise { + let updateComplete = false; + let retryCount = 0; + let lastStatus = ""; + let lastProgress = 0; + let lastOpId = ""; + + terminal.writeln(terminalLog.subheader("Monitoring database update progress")); + + while (!updateComplete && retryCount < MAX_RETRY_COUNT) { + // Check if the VNet is now in the database account + const updatedDbAccount = await getAccountDetails(dbAccountId); + + const isVNetAdded = updatedDbAccount.properties.virtualNetworkRules?.some( + (rule: any) => rule.id === cloudShellSubnetId && (!rule.status || rule.status === 'Succeeded') + ); + + if (isVNetAdded) { + updateComplete = true; + terminal.writeln(terminalLog.success("CloudShell VNet successfully added to database configuration")); + break; + } + + // If not yet added, check for operation progress + const operations = await getDatabaseOperations(dbAccountId); + + // Find network-related operations + const networkOps = operations.value?.filter( + (op: any) => + (op.properties.description?.toLowerCase().includes('network') || + op.properties.description?.toLowerCase().includes('vnet')) + ) || []; + + // Find active operations + const activeOp = networkOps.find((op: any) => op.properties.status === 'InProgress'); + + if (activeOp) { + // Show progress details if available + const currentStatus = activeOp.properties.status; + const progress = activeOp.properties.percentComplete || 0; + const opId = activeOp.name; + + // Only update the terminal if something has changed + if (currentStatus !== lastStatus || progress !== lastProgress || opId !== lastOpId) { + // Create a progress bar + const progressBarLength = 20; + const filledLength = Math.floor(progress / 100 * progressBarLength); + const progressBar = "█".repeat(filledLength) + "░".repeat(progressBarLength - filledLength); + + terminal.writeln(`\x1B[34m [${progressBar}] ${progress}% - ${currentStatus}\x1B[0m`); + lastStatus = currentStatus; + lastProgress = progress; + lastOpId = opId; + } + } else if (networkOps.length > 0) { + // If there are completed operations, show their status + const lastCompletedOp = networkOps[0]; + + if (lastCompletedOp.properties.status !== lastStatus) { + terminal.writeln(terminalLog.progress("Operation status", lastCompletedOp.properties.status)); + lastStatus = lastCompletedOp.properties.status; + } + } + + retryCount++; + await wait(POLLING_INTERVAL_MS); + } + + if (!updateComplete) { + terminal.writeln(terminalLog.warning("Database update timed out. Please check the Azure portal.")); + } + } + + /** + * Configures a new VNet for CloudShell + */ + public static async configureCloudShellVNet(terminal: Terminal, resolvedRegion: string): Promise { + // Use professional and shorter names for resources + const randomSuffix = Math.floor(10000 + Math.random() * 90000); + + const subnetName = `cloudshell-subnet-${randomSuffix}`; + const vnetName = `cloudshell-vnet-${randomSuffix}`; + const networkProfileName = `cloudshell-network-profile-${randomSuffix}`; + const relayName = `cloudshell-relay-${randomSuffix}`; + + terminal.writeln(terminalLog.header("Network Resource Configuration")); + + const azureContainerInstanceOID = await askQuestion( + terminal, + "Enter Azure Container Instance OID (Refer. https://learn.microsoft.com/en-us/azure/cloud-shell/vnet/deployment#get-the-azure-container-instance-id)", + DEFAULT_CONTAINER_INSTANCE_OID + ); + + const vNetSubscriptionId = await askQuestion( + terminal, + "Enter Virtual Network Subscription ID", + userContext.subscriptionId + ); + + const vNetResourceGroup = await askQuestion( + terminal, + "Enter Virtual Network Resource Group", + userContext.resourceGroup + ); + + // Step 1: Create VNet with Subnet + terminal.writeln(terminalLog.header("Deploying Network Resources")); + const vNetConfigPayload = await this.createCloudShellVnet( + resolvedRegion, + subnetName, + terminal, + vnetName, + vNetSubscriptionId, + vNetResourceGroup + ); + + // Step 2: Create Network Profile + await this.createNetworkProfileWithVnet( + vNetSubscriptionId, + vNetResourceGroup, + vnetName, + subnetName, + resolvedRegion, + terminal, + networkProfileName + ); + + // Step 3: Create Network Relay + await this.createNetworkRelay( + resolvedRegion, + terminal, + relayName, + vNetSubscriptionId, + vNetResourceGroup + ); + + // Step 4: Assign Roles + terminal.writeln(terminalLog.header("Configuring Security Permissions")); + await this.assignRoleToNetworkProfile( + azureContainerInstanceOID, + vNetSubscriptionId, + terminal, + networkProfileName, + vNetResourceGroup + ); + + await this.assignRoleToRelay( + azureContainerInstanceOID, + vNetSubscriptionId, + terminal, + relayName, + vNetResourceGroup + ); + + // Step 5: Create and return VNet settings + const networkProfileResourceId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName.replace(/[\n\r]/g, "")}`; + const relayResourceId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName.replace(/[\n\r]/g, "")}`; + + terminal.writeln(terminalLog.success("Network configuration complete")); + + return { + networkProfileResourceId, + relayNamespaceResourceId: relayResourceId, + location: vNetConfigPayload.location + }; + } + + /** + * Creates a VNet for CloudShell + */ + private static async createCloudShellVnet( + resolvedRegion: string, + subnetName: string, + terminal: Terminal, + vnetName: string, + vNetSubscriptionId: string, + vNetResourceGroup: string + ): Promise { + const vNetConfigPayload = { + location: resolvedRegion, + properties: { + addressSpace: { + addressPrefixes: [DEFAULT_VNET_ADDRESS_PREFIX], + }, + subnets: [ + { + name: subnetName, + properties: { + addressPrefix: DEFAULT_SUBNET_ADDRESS_PREFIX, + delegations: [ + { + name: "CloudShellDelegation", + properties: { + serviceName: "Microsoft.ContainerInstance/containerGroups" + } + } + ], + }, + }, + ], + }, + }; + + terminal.writeln(terminalLog.vnet(`Creating VNet: ${vnetName}`)); + let vNetResponse = await updateVnet( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}`, + vNetConfigPayload + ); + + while (vNetResponse?.properties?.provisioningState !== "Succeeded") { + vNetResponse = await getVnet( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}` + ); + + const vNetState = vNetResponse?.properties?.provisioningState; + if (vNetState !== "Succeeded" && vNetState !== "Failed") { + await wait(POLLING_INTERVAL_MS); + terminal.writeln(terminalLog.progress("VNet deployment", vNetState)); + } else { + break; + } + } + + terminal.writeln(terminalLog.success("VNet created successfully")); + return vNetConfigPayload; + } + + /** + * Creates a Network Profile for CloudShell + */ + private static async createNetworkProfileWithVnet( + vNetSubscriptionId: string, + vNetResourceGroup: string, + vnetName: string, + subnetName: string, + resolvedRegion: string, + terminal: Terminal, + networkProfileName: string + ): Promise { + const subnetId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}/subnets/${subnetName}`; + + const createNetworkProfilePayload = { + location: resolvedRegion, + properties: { + containerNetworkInterfaceConfigurations: [ + { + name: 'defaultContainerNicConfig', + properties: { + ipConfigurations: [ + { + name: 'defaultContainerIpConfig', + properties: { + subnet: { + id: subnetId, + } + } + } + ] + } + } + ] + } + }; + + terminal.writeln(terminalLog.vnet("Creating Network Profile")); + let networkProfileResponse = await createNetworkProfile( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}`, + createNetworkProfilePayload + ); + + while (networkProfileResponse?.properties?.provisioningState !== "Succeeded") { + networkProfileResponse = await getNetworkProfileInfo( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}` + ); + + const networkProfileState = networkProfileResponse?.properties?.provisioningState; + if (networkProfileState !== "Succeeded" && networkProfileState !== "Failed") { + await wait(POLLING_INTERVAL_MS); + terminal.writeln(terminalLog.progress("Network Profile", networkProfileState)); + } else { + break; + } + } + + terminal.writeln(terminalLog.success("Network Profile created successfully")); + } + + /** + * Creates a Network Relay for CloudShell + */ + private static async createNetworkRelay( + resolvedRegion: string, + terminal: Terminal, + relayName: string, + vNetSubscriptionId: string, + vNetResourceGroup: string + ): Promise { + const relayPayload = { + location: resolvedRegion, + sku: { + name: STANDARD_SKU, + tier: STANDARD_SKU, + } + }; + + terminal.writeln(terminalLog.vnet("Creating Relay Namespace")); + let relayResponse = await createRelay( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}`, + relayPayload + ); + + while (relayResponse?.properties?.provisioningState !== "Succeeded") { + relayResponse = await getRelay( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}` + ); + + const relayState = relayResponse?.properties?.provisioningState; + if (relayState !== "Succeeded" && relayState !== "Failed") { + await wait(POLLING_INTERVAL_MS); + terminal.writeln(terminalLog.progress("Relay Namespace", relayState)); + } else { + break; + } + } + + terminal.writeln(terminalLog.success("Relay Namespace created successfully")); + } + + /** + * Assigns a role to a Network Profile + */ + private static async assignRoleToNetworkProfile( + azureContainerInstanceOID: string, + vNetSubscriptionId: string, + terminal: Terminal, + networkProfileName: string, + vNetResourceGroup: string + ): Promise { + const nfRoleName = uuidv4(); + const networkProfileRoleAssignmentPayload = { + properties: { + principalId: azureContainerInstanceOID, + roleDefinitionId: `/subscriptions/${vNetSubscriptionId}/providers/Microsoft.Authorization/roleDefinitions/4d97b98b-1d4f-4787-a291-c67834d212e7` + } + }; + + terminal.writeln(terminalLog.info("Assigning permissions to Network Profile")); + await createRoleOnNetworkProfile( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}/providers/Microsoft.Authorization/roleAssignments/${nfRoleName}`, + networkProfileRoleAssignmentPayload + ); + + terminal.writeln(terminalLog.success("Network Profile permissions assigned")); + } + + /** + * Assigns a role to a Network Relay + */ + private static async assignRoleToRelay( + azureContainerInstanceOID: string, + vNetSubscriptionId: string, + terminal: Terminal, + relayName: string, + vNetResourceGroup: string + ): Promise { + const relayRoleName = uuidv4(); + const relayRoleAssignmentPayload = { + properties: { + principalId: azureContainerInstanceOID, + roleDefinitionId: `/subscriptions/${vNetSubscriptionId}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c`, + } + }; + + terminal.writeln(terminalLog.info("Assigning permissions to Relay Namespace")); + await createRoleOnRelay( + `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}/providers/Microsoft.Authorization/roleAssignments/${relayRoleName}`, + relayRoleAssignmentPayload + ); + + terminal.writeln(terminalLog.success("Relay Namespace permissions assigned")); + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.tsx new file mode 100644 index 000000000..e56d1f7d8 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Cassandra shell type handler + */ + +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { userContext } from "../../../../UserContext"; +import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { setShellType } from "../Data/CloudShellApiClient"; +import { NetworkAccessHandler } from "../Network/NetworkAccessHandler"; +import { getHostFromUrl } from "../Utils/CommonUtils"; +import { ShellTypeConfig } from "./ShellTypeFactory"; + +export class CassandraShellHandler implements ShellTypeConfig { + private shellType: TerminalKind = TerminalKind.Cassandra; + + constructor() { + setShellType(this.shellType); + } + + public getShellName(): string { + return "Cassandra"; + } + + public async getInitialCommands(): Promise { + const dbAccount = userContext.databaseAccount; + const endpoint = dbAccount.properties.cassandraEndpoint; + + // Get database key + const dbName = dbAccount.name; + let key = ""; + if (dbName) { + const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); + key = keys?.primaryMasterKey || ""; + } + + const config = { + host: getHostFromUrl(endpoint), + name: dbAccount.name, + password: key, + endpoint: endpoint + }; + + return this.getCommands(config).join("\n").concat("\n"); + } + + public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{ + vNetSettings: any; + isAllPublicAccessEnabled: boolean; + }> { + return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType); + } + + private getCommands(config: any): string[] { + 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 cqlsh is installed; if not, proceed with installation + "if ! command -v cqlsh &> /dev/null; then echo '⚠️ cqlsh not found. Installing...'; fi", + // 3. Download Cassandra if not installed + "if ! command -v cqlsh &> /dev/null; then curl -LO https://archive.apache.org/dist/cassandra/5.0.3/apache-cassandra-5.0.3-bin.tar.gz; fi", + // 4. Extract Cassandra package if not installed + "if ! command -v cqlsh &> /dev/null; then tar -xvzf apache-cassandra-5.0.3-bin.tar.gz; fi", + // 5. Move Cassandra binaries if not installed + "if ! command -v cqlsh &> /dev/null; then mkdir -p ~/cassandra && mv apache-cassandra-5.0.3/* ~/cassandra/; fi", + // 6. Add Cassandra to PATH if not installed + "if ! command -v cqlsh &> /dev/null; then echo 'export PATH=$HOME/cassandra/bin:$PATH' >> ~/.bashrc; fi", + // 7. Set environment variables for SSL + "if ! command -v cqlsh &> /dev/null; then echo 'export SSL_VERSION=TLSv1_2' >> ~/.bashrc; fi", + "if ! command -v cqlsh &> /dev/null; then echo 'export SSL_VALIDATE=false' >> ~/.bashrc; fi", + // 8. Source .bashrc to update PATH (even if cqlsh was already installed) + "source ~/.bashrc", + // 9. Verify cqlsh installation + "cqlsh --version", + // 10. Login to Cassandra + `cqlsh ${config.host} 10350 -u ${config.name} -p ${config.password} --ssl --protocol-version=4` + ]; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.tsx new file mode 100644 index 000000000..c332e7cd1 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.tsx @@ -0,0 +1,77 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Mongo shell type handler + */ + +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { userContext } from "../../../../UserContext"; +import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { setShellType } from "../Data/CloudShellApiClient"; +import { NetworkAccessHandler } from "../Network/NetworkAccessHandler"; +import { getHostFromUrl } from "../Utils/CommonUtils"; +import { ShellTypeConfig } from "./ShellTypeFactory"; + +export class MongoShellHandler implements ShellTypeConfig { + private shellType: TerminalKind = TerminalKind.Mongo; + + constructor() { + setShellType(this.shellType); + } + + public getShellName(): string { + return "MongoDB"; + } + + public async getInitialCommands(): Promise { + const dbAccount = userContext.databaseAccount; + const endpoint = dbAccount.properties.mongoEndpoint; + + // Get database key + const dbName = dbAccount.name; + let key = ""; + if (dbName) { + const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); + key = keys?.primaryMasterKey || ""; + } + + const config = { + host: getHostFromUrl(endpoint), + name: dbAccount.name, + password: key, + endpoint: endpoint + }; + + return this.getCommands(config).join("\n").concat("\n"); + } + + public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{ + vNetSettings: any; + isAllPublicAccessEnabled: boolean; + }> { + return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType); + } + + private getCommands(config: any): string[] { + 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 MongoDB + `mongosh --host ${config.host} --port 10255 --username ${config.name} --password ${config.password} --tls --tlsAllowInvalidCertificates` + ]; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.tsx new file mode 100644 index 000000000..3a6ab1da3 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * PostgreSQL shell type handler + */ + +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { userContext } from "../../../../UserContext"; +import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { setShellType } from "../Data/CloudShellApiClient"; +import { NetworkAccessHandler } from "../Network/NetworkAccessHandler"; +import { getHostFromUrl } from "../Utils/CommonUtils"; +import { ShellTypeConfig } from "./ShellTypeFactory"; + +export class PostgresShellHandler implements ShellTypeConfig { + private shellType: TerminalKind = TerminalKind.Postgres; + + constructor() { + setShellType(this.shellType); + } + + public getShellName(): string { + return "PostgreSQL"; + } + + public async getInitialCommands(): Promise { + const dbAccount = userContext.databaseAccount; + const endpoint = dbAccount.properties.postgresqlEndpoint; + + // Get database key + const dbName = dbAccount.name; + let key = ""; + if (dbName) { + const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); + key = keys?.primaryMasterKey || ""; + } + + const config = { + host: getHostFromUrl(endpoint), + name: dbAccount.name, + password: key, + endpoint: endpoint + }; + + return this.getCommands(config).join("\n").concat("\n"); + } + + public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{ + vNetSettings: any; + isAllPublicAccessEnabled: boolean; + }> { + return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType); + } + + private getCommands(config: any): string[] { + 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 psql is installed; if not, proceed with installation + "if ! command -v psql &> /dev/null; then echo '⚠️ psql not found. Installing...'; fi", + // 3. Download PostgreSQL if not installed + "if ! command -v psql &> /dev/null; then curl -LO https://ftp.postgresql.org/pub/source/v15.2/postgresql-15.2.tar.bz2; fi", + // 4. Extract PostgreSQL package if not installed + "if ! command -v psql &> /dev/null; then tar -xvjf postgresql-15.2.tar.bz2; fi", + // 5. Create a directory for PostgreSQL installation if not installed + "if ! command -v psql &> /dev/null; then mkdir -p ~/pgsql; fi", + // 6. Download readline (dependency for PostgreSQL) if not installed + "if ! command -v psql &> /dev/null; then curl -LO https://ftp.gnu.org/gnu/readline/readline-8.1.tar.gz; fi", + // 7. Extract readline package if not installed + "if ! command -v psql &> /dev/null; then tar -xvzf readline-8.1.tar.gz; fi", + // 8. Configure readline if not installed + "if ! command -v psql &> /dev/null; then cd readline-8.1 && ./configure --prefix=$HOME/pgsql; fi", + // 9. Add PostgreSQL to PATH if not installed + "if ! command -v psql &> /dev/null; then echo 'export PATH=$HOME/pgsql/bin:$PATH' >> ~/.bashrc; fi", + // 10. Source .bashrc to update PATH (even if psql was already installed) + "source ~/.bashrc", + // 11. Verify PostgreSQL installation + "psql --version", + `psql 'read -p "Enter Database Name: " dbname && read -p "Enter Username: " username && host=${config.endpoint} port=5432 dbname=$dbname user=$username sslmode=require'` + ]; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx new file mode 100644 index 000000000..04fda0deb --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Factory for creating shell type handlers + */ + +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { CassandraShellHandler } from "./CassandraShellHandler"; +import { MongoShellHandler } from "./MongoShellHandler"; +import { PostgresShellHandler } from "./PostgresShellHandler"; +import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler"; + +export interface ShellTypeConfig { + getShellName(): string; + getInitialCommands(): Promise; + configureNetworkAccess(terminal: Terminal, region: string): Promise<{ + vNetSettings: any; + isAllPublicAccessEnabled: boolean; + }>; +} + +export class ShellTypeHandler { + /** + * Gets the appropriate handler for the given shell type + */ + public static getHandler(shellType: TerminalKind): ShellTypeConfig { + switch (shellType) { + case TerminalKind.Postgres: + return new PostgresShellHandler(); + case TerminalKind.Mongo: + return new MongoShellHandler(); + case TerminalKind.VCoreMongo: + return new VCoreMongoShellHandler(); + case TerminalKind.Cassandra: + return new CassandraShellHandler(); + default: + throw new Error(`Unsupported shell type: ${shellType}`); + } + } + + /** + * Gets the display name for a shell type + */ + public static getShellNameForDisplay(terminalKind: TerminalKind): string { + switch (terminalKind) { + case TerminalKind.Postgres: + return "PostgreSQL"; + case TerminalKind.Mongo: + case TerminalKind.VCoreMongo: + return "MongoDB"; + case TerminalKind.Cassandra: + return "Cassandra"; + default: + return ""; + } + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.tsx new file mode 100644 index 000000000..dedfea1fb --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * VCore MongoDB shell type handler + */ + +import { Terminal } from "xterm"; +import { TerminalKind } from "../../../../Contracts/ViewModels"; +import { userContext } from "../../../../UserContext"; +import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { setShellType } from "../Data/CloudShellApiClient"; +import { NetworkAccessHandler } from "../Network/NetworkAccessHandler"; +import { getHostFromUrl } from "../Utils/CommonUtils"; +import { ShellTypeConfig } from "./ShellTypeFactory"; + +export class VCoreMongoShellHandler implements ShellTypeConfig { + private shellType: TerminalKind = TerminalKind.VCoreMongo; + + constructor() { + setShellType(this.shellType); + } + + public getShellName(): string { + return "MongoDB VCore"; + } + + public async getInitialCommands(): Promise { + const dbAccount = userContext.databaseAccount; + const endpoint = dbAccount.properties.vcoreMongoEndpoint; + + // Get database key + const dbName = dbAccount.name; + let key = ""; + if (dbName) { + const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); + key = keys?.primaryMasterKey || ""; + } + + const config = { + host: getHostFromUrl(endpoint), + name: dbAccount.name, + password: key, + endpoint: endpoint + }; + + return this.getCommands(config).join("\n").concat("\n"); + } + + public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{ + vNetSettings: any; + isAllPublicAccessEnabled: boolean; + }> { + // VCore MongoDB uses private endpoints + return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType); + } + + private getCommands(config: any): string[] { + 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 MongoDB + `read -p "Enter username: " username && mongosh "mongodb+srv://$username:@${config.endpoint}/?authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000" --tls --tlsAllowInvalidCertificates` + ]; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx deleted file mode 100644 index 1ee89487b..000000000 --- a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx +++ /dev/null @@ -1,1291 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. All rights reserved. - */ - -import { Settings, VnetSettings } from "Explorer/Tabs/CloudShellTab/DataModels"; -import { listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; -import { v4 as uuidv4 } from 'uuid'; -import { Terminal } from "xterm"; -import { TerminalKind } from "../../../Contracts/ViewModels"; -import { - IsPublicAccessAvailable, - hasPrivateEndpointsRestrictions, - hasVNetRestrictions -} from "Explorer/Tabs/Shared/CheckFirewallRules"; -import { userContext } from "../../../UserContext"; -import { AttachAddon } from "./AttachAddOn"; -import { getCommands } from "./Commands"; -import { - authorizeSession, - connectTerminal, - createNetworkProfile, - createRelay, - createRoleOnNetworkProfile, - createRoleOnRelay, - getAccountDetails, - getDatabaseOperations, - getNetworkProfileInfo, - getNormalizedRegion, - getRelay, - getSubnetInformation, - getUserSettings, - getVnet, - getVnetInformation, - provisionConsole, - putEphemeralUserSettings, - registerCloudShellProvider, - setShellType, - updateDatabaseAccount, - updateSubnetInformation, - updateVnet, - verifyCloudShellProviderRegistration -} from "./Data"; -import { terminalLog } from "./LogFormatter"; - -// Constants -const DEFAULT_CLOUDSHELL_REGION = "westus"; -const POLLING_INTERVAL_MS = 5000; -const MAX_RETRY_COUNT = 10; -const MAX_PING_COUNT = 20 * 60; // 20 minutes (60 seconds/minute) -const STANDARD_SKU = "Standard"; -const DEFAULT_VNET_ADDRESS_PREFIX = "10.0.0.0/16"; -const DEFAULT_SUBNET_ADDRESS_PREFIX = "10.0.1.0/24"; -const DEFAULT_CONTAINER_INSTANCE_OID = "88536fb9-d60a-4aee-8195-041425d6e927"; - -/** - * Main function to start a CloudShell terminal - */ -export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind) => { - // Set the shell type to use the appropriate API version for all calls - setShellType(shellType); - - terminal.writeln(terminalLog.header("Initializing Azure CloudShell")); - await ensureCloudShellProviderRegistered(terminal); - - const { resolvedRegion, defaultCloudShellRegion } = determineCloudShellRegion(terminal); - - // Ask for user consent for region - const consentGranted = await askForRegionConsent(terminal, resolvedRegion); - if (!consentGranted) { - return {}; // Exit if user declined - } - - const isAllPublicAccessEnabled = await IsPublicAccessAvailable(shellType); - - let settings: Settings | undefined; - let cloudShellVnetSettings: VnetSettings | undefined; - let finalVNetSettings: VnetSettings | {}; - - if (!isAllPublicAccessEnabled) { - // Fetch and process user settings for restricted networks - terminal.writeln(terminalLog.database("Network restrictions detected")); - terminal.writeln(terminalLog.info("Loading CloudShell configuration...")); - settings = await getUserSettings(); - if (!settings) { - terminal.writeln(terminalLog.warning("No existing user settings found.")); - } - else { - cloudShellVnetSettings = await retrieveCloudShellVnetSettings(settings, terminal); - } - - // If CloudShell has VNet settings, check with database config - if (cloudShellVnetSettings && cloudShellVnetSettings.networkProfileResourceId) { - - //TODO: askForVNetConfigConsent consent should change to support private end point - // if shell type is vcore or for any other shell as private endpoint restrictions - const isContinueWithSameVnet = await askForVNetConfigConsent(terminal); - - if(isContinueWithSameVnet) { - // TODO: isCloudShellVNetInDatabaseConfig call should handle private endpoint check also. - // a private endpoint is associated with the vnet and subnet. - // vcore supports only private endpoint, it doesnot support vnet, dbAccount?.properties?.privateEndpointConnections doesn't have any info. ARM call might needs to make to get private endpoint info - const isVNetInDatabaseConfig = await isCloudShellVNetInDatabaseConfig(cloudShellVnetSettings, terminal); - - if (!isVNetInDatabaseConfig) { - terminal.writeln(terminalLog.warning("CloudShell VNet is not configured in database access list")); - // TODO: Add logic in askToAddVNetToDatabase to ask accordingly if user has private endpoint or vnet settings - const addToDatabase = await askToAddVNetToDatabase(terminal, cloudShellVnetSettings); - - if (addToDatabase) { - // TODO: Add a logic to add private endpoint to database if user has private endpoint settings in the database - await addCloudShellVNetToDatabase(cloudShellVnetSettings, terminal); - } else { - // User declined to add VNet to database, need to recreate - terminal.writeln(terminalLog.warning("Will configure new VNet...")); - cloudShellVnetSettings = undefined; - } - } else { - terminal.writeln(terminalLog.success("CloudShell VNet is already in database configuration")); - } - } - else { - cloudShellVnetSettings = undefined; // User declined to use existing VNet settings - } - } - - if (!cloudShellVnetSettings || !cloudShellVnetSettings.networkProfileResourceId) { - - //TODO: Add logic to check if the user has existing VNet or private endpoint (which would be associated with the Vnet) settings in the database with cloudshell as CloudShellDelegation, - // if yes, use that vnet to get network profile and get the existing relay and - // use that for cloudshell, else create only those resources which are required and ternminal write the existing relay and vnet settings.which it is going to use. - terminal.writeln(terminalLog.subheader("Configuring network infrastructure")); - finalVNetSettings = await configureCloudShellVNet(terminal, resolvedRegion); - - // TODO: Add a logic to add private endpoint to database if user has private endpoint settings in the database - await addCloudShellVNetToDatabase(finalVNetSettings as VnetSettings, terminal); - } else { - terminal.writeln(terminalLog.success("Using existing network configuration")); - finalVNetSettings = cloudShellVnetSettings; - } - } else { - terminal.writeln(terminalLog.database("Public access enabled. Skipping VNet configuration.")); - } - - terminal.writeln(""); - // Provision CloudShell session - terminal.writeln(terminalLog.cloudshell(`Provisioning Started....`)); - - let sessionDetails: { - socketUri?: string; - provisionConsoleResponse?: any; - targetUri?: string; - }; - - try { - sessionDetails = await provisionCloudShellSession(resolvedRegion, terminal, finalVNetSettings, isAllPublicAccessEnabled); - } catch (err) { - terminal.writeln(terminalLog.error(err)); - terminal.writeln(terminalLog.error("Failed to provision in primary region")); - terminal.writeln(terminalLog.warning(`Attempting with fallback region: ${defaultCloudShellRegion}`)); - - sessionDetails = await provisionCloudShellSession(defaultCloudShellRegion, terminal, finalVNetSettings, isAllPublicAccessEnabled); - } - - if (!sessionDetails.socketUri) { - terminal.writeln(terminalLog.error('Unable to provision console. Please try again later.')); - return {}; - } - - // Configure WebSocket connection - const socket = await establishTerminalConnection( - terminal, - shellType, - sessionDetails.socketUri, - sessionDetails.provisionConsoleResponse, - sessionDetails.targetUri - ); - - return socket; -}; - -/** - * Asks the user if they want to use existing network configuration (VNet or private endpoint) - */ -const askForVNetConfigConsent = async (terminal: Terminal, shellType: TerminalKind = null): Promise => { - // Check if this shell type supports only private endpoints - const isPrivateEndpointOnlyShell = shellType === TerminalKind.VCoreMongo; - // Check if the database has private endpoints configured - const hasPrivateEndpoints = hasPrivateEndpointsRestrictions(); - - // Determine which network type to mention based on shell type and database configuration - const networkType = isPrivateEndpointOnlyShell || hasPrivateEndpoints ? "private endpoint" : "network"; - - // Ask for consent - terminal.writeln(""); - terminal.writeln(terminalLog.prompt(`Use this existing ${networkType} configuration? (Y/N)`)); - terminal.writeln(terminalLog.info(`Answering 'N' will configure a new ${networkType} for CloudShell`)); - - return new Promise((resolve) => { - const keyListener = terminal.onKey(({ key }: { key: string }) => { - keyListener.dispose(); - terminal.writeln(""); - - if (key.toLowerCase() === 'y') { - terminal.writeln(terminalLog.success(`Proceeding with existing ${networkType} configuration`)); - resolve(true); - } else { - terminal.writeln(terminalLog.info(`Will configure new ${networkType} settings`)); - resolve(false); - } - }); - }); -}; - -/** - * Checks if the CloudShell VNet is already in the database configuration - */ -const isCloudShellVNetInDatabaseConfig = async (vNetSettings: VnetSettings, terminal: Terminal): Promise => { - try { - terminal.writeln(terminalLog.subheader("Verifying if CloudShell VNet is configured in database")); - - // Get the subnet ID from the CloudShell Network Profile - const netProfileInfo = await getNetworkProfileInfo(vNetSettings.networkProfileResourceId); - - if (!netProfileInfo?.properties?.containerNetworkInterfaceConfigurations?.[0] - ?.properties?.ipConfigurations?.[0]?.properties?.subnet?.id) { - terminal.writeln(terminalLog.warning("Could not retrieve subnet ID from CloudShell VNet")); - return false; - } - - const cloudShellSubnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0] - .properties.ipConfigurations[0].properties.subnet.id; - - terminal.writeln(terminalLog.item("CloudShell Subnet", cloudShellSubnetId.split('/').pop() || "")); - - // Check if this subnet ID is in the database VNet rules - const dbAccount = userContext.databaseAccount; - if (!dbAccount?.properties?.virtualNetworkRules) { - return false; - } - - const vnetRules = dbAccount.properties.virtualNetworkRules; - - // Check if the CloudShell subnet is already in the rules - return vnetRules.some(rule => rule.id === cloudShellSubnetId); - - } catch (err) { - terminal.writeln(terminalLog.error("Error checking database VNet configuration")); - return false; - } -}; - -/** - * Asks the user if they want to add the CloudShell VNet to the database configuration - */ -const askToAddVNetToDatabase = async (terminal: Terminal, vNetSettings: VnetSettings): Promise => { - terminal.writeln(""); - terminal.writeln(terminalLog.header("Network Configuration Mismatch")); - terminal.writeln(terminalLog.warning("Your CloudShell VNet is not in your database's allowed networks")); - terminal.writeln(terminalLog.warning("To connect from CloudShell, this VNet must be added to your database")); - terminal.writeln(terminalLog.prompt("Add CloudShell VNet to database configuration? (y/n)")); - - return new Promise((resolve) => { - const keyListener = terminal.onKey(({ key }: { key: string }) => { - keyListener.dispose(); - terminal.writeln(""); - - if (key.toLowerCase() === 'y') { - terminal.writeln(terminalLog.success("Proceeding to add VNet to database")); - resolve(true); - } else { - terminal.writeln(terminalLog.warning("Skipping VNet configuration for database")); - resolve(false); - } - }); - }); -}; - -/** - * Adds the CloudShell VNet to the database configuration - */ -const addCloudShellVNetToDatabase = async (vNetSettings: VnetSettings, terminal: Terminal): Promise => { - try { - terminal.writeln(terminalLog.header("Updating database network configuration")); - - // Step 1: Get the subnet ID from CloudShell Network Profile - const { cloudShellSubnetId, cloudShellVnetId } = await getCloudShellNetworkIds(vNetSettings, terminal); - - // Step 2: Get current database account details - const { currentDbAccount } = await getDatabaseAccountDetails(terminal); - - // Step 3: Check if VNet is already configured in database - if (await isVNetAlreadyConfigured(cloudShellSubnetId, currentDbAccount, terminal)) { - return; - } - - // Step 4: Check network resource statuses - const { vnetInfo, subnetInfo, operationInProgress } = - await checkNetworkResourceStatuses(cloudShellSubnetId, cloudShellVnetId, currentDbAccount.id, terminal); - - // Step 5: If no operation in progress, update the subnet and database - if (!operationInProgress) { - // Step 5a: Enable CosmosDB service endpoint on subnet if needed - await enableCosmosDBServiceEndpoint(cloudShellSubnetId, subnetInfo, terminal); - - // Step 5b: Update database account with VNet rule - await updateDatabaseWithVNetRule(currentDbAccount, cloudShellSubnetId, currentDbAccount.id, terminal); - } else { - terminal.writeln(terminalLog.info("Monitoring existing VNet operation...")); - // Step 6: Monitor the update progress - await monitorVNetAdditionProgress(cloudShellSubnetId, currentDbAccount.id, terminal); - } - - } catch (err) { - terminal.writeln(terminalLog.error(`Error updating database network configuration: ${err.message}`)); - throw err; - } -}; - -/** - * Gets the subnet and VNet IDs from CloudShell Network Profile - */ -const getCloudShellNetworkIds = async (vNetSettings: VnetSettings, terminal: Terminal): Promise<{ cloudShellSubnetId: string; cloudShellVnetId: string }> => { - const netProfileInfo = await getNetworkProfileInfo(vNetSettings.networkProfileResourceId); - - if (!netProfileInfo?.properties?.containerNetworkInterfaceConfigurations?.[0] - ?.properties?.ipConfigurations?.[0]?.properties?.subnet?.id) { - throw new Error("Could not retrieve subnet ID from CloudShell VNet"); - } - - const cloudShellSubnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0] - .properties.ipConfigurations[0].properties.subnet.id; - - // Extract VNet ID from subnet ID - const cloudShellVnetId = cloudShellSubnetId.substring(0, cloudShellSubnetId.indexOf('/subnets/')); - - terminal.writeln(terminalLog.subheader("Identified CloudShell network resources")); - terminal.writeln(terminalLog.item("Subnet", cloudShellSubnetId.split('/').pop() || "")); - terminal.writeln(terminalLog.item("VNet", cloudShellVnetId.split('/').pop() || "")); - - return { cloudShellSubnetId, cloudShellVnetId }; -}; - -/** - * Gets the database account details - */ -const getDatabaseAccountDetails = async (terminal: Terminal): Promise<{ currentDbAccount: any }> => { - const dbAccount = userContext.databaseAccount; - terminal.writeln(terminalLog.database("Verifying current configuration")); - const currentDbAccount = await getAccountDetails(dbAccount.id); - - return { currentDbAccount }; -}; - -/** - * Checks if the VNet is already configured in the database - */ -const isVNetAlreadyConfigured = async (cloudShellSubnetId: string, currentDbAccount: any, terminal: Terminal): Promise => { - const vnetAlreadyConfigured = currentDbAccount.properties.virtualNetworkRules && - currentDbAccount.properties.virtualNetworkRules.some( - (rule: any) => rule.id === cloudShellSubnetId - ); - - if (vnetAlreadyConfigured) { - terminal.writeln(terminalLog.success("CloudShell VNet is already in database configuration")); - return true; - } - - return false; -}; - -/** - * Checks the status of network resources and ongoing operations - */ -const checkNetworkResourceStatuses = async ( - cloudShellSubnetId: string, - cloudShellVnetId: string, - dbAccountId: string, - terminal: Terminal -): Promise<{ vnetInfo: any; subnetInfo: any; operationInProgress: boolean }> => { - terminal.writeln(terminalLog.subheader("Checking network resource status")); - - let operationInProgress = false; - let vnetInfo: any = null; - let subnetInfo: any = null; - - if (cloudShellVnetId && cloudShellSubnetId) { - // Get VNet and subnet resource status - vnetInfo = await getVnetInformation(cloudShellVnetId); - subnetInfo = await getSubnetInformation(cloudShellSubnetId); - - // Check if there's an ongoing operation on the VNet or subnet - const vnetProvisioningState = vnetInfo?.properties?.provisioningState; - const subnetProvisioningState = subnetInfo?.properties?.provisioningState; - - if (vnetProvisioningState !== 'Succeeded' && vnetProvisioningState !== 'Failed') { - terminal.writeln(terminalLog.warning(`VNet operation in progress: ${vnetProvisioningState}`)); - operationInProgress = true; - } - - if (subnetProvisioningState !== 'Succeeded' && subnetProvisioningState !== 'Failed') { - terminal.writeln(terminalLog.warning(`Subnet operation in progress: ${subnetProvisioningState}`)); - operationInProgress = true; - } - - // Also check database operations - const latestDbAccount = await getAccountDetails(dbAccountId); - - if (latestDbAccount.properties.virtualNetworkRules) { - const isPendingAdd = latestDbAccount.properties.virtualNetworkRules.some( - (rule: any) => rule.id === cloudShellSubnetId && rule.status === 'Updating' - ); - - if (isPendingAdd) { - terminal.writeln(terminalLog.warning("CloudShell VNet addition to database is already in progress")); - operationInProgress = true; - } - } - } - - return { vnetInfo, subnetInfo, operationInProgress }; -}; - -/** - * Enables the CosmosDB service endpoint on a subnet if needed - */ -const enableCosmosDBServiceEndpoint = async (cloudShellSubnetId: string, subnetInfo: any, terminal: Terminal): Promise => { - if (!subnetInfo) { - terminal.writeln(terminalLog.warning("Unable to check subnet endpoint configuration")); - return; - } - - terminal.writeln(terminalLog.subheader("Checking and configuring CosmosDB service endpoint")); - - // Parse the subnet ID to get resource information - const subnetIdParts = cloudShellSubnetId.split('/'); - const subnetIndex = subnetIdParts.indexOf('subnets'); - if (subnetIndex > 0) { - const subnetName = subnetIdParts[subnetIndex + 1]; - const vnetName = subnetIdParts[subnetIndex - 1]; - const resourceGroup = subnetIdParts[4]; - const subscriptionId = subnetIdParts[2]; - - // Get the subnet URL - const subnetUrl = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}/subnets/${subnetName}`; - - // Check if CosmosDB service endpoint is already enabled - const hasCosmosDBEndpoint = subnetInfo.properties.serviceEndpoints && - subnetInfo.properties.serviceEndpoints.some( - (endpoint: any) => endpoint.service === 'Microsoft.AzureCosmosDB' - ); - - if (!hasCosmosDBEndpoint) { - terminal.writeln(terminalLog.warning("Enabling CosmosDB service endpoint on subnet...")); - - // Create update payload with CosmosDB service endpoint - const serviceEndpoints = [ - ...(subnetInfo.properties.serviceEndpoints || []), - { service: 'Microsoft.AzureCosmosDB' } - ]; - - // Update the subnet configuration while preserving existing properties - const subnetUpdatePayload = { - ...subnetInfo, - properties: { - ...subnetInfo.properties, - serviceEndpoints: serviceEndpoints - } - }; - - // Apply the subnet update - await updateSubnetInformation(subnetUrl, subnetUpdatePayload); - - // Wait for the subnet update to complete - let subnetUpdateComplete = false; - let subnetRetryCount = 0; - - while (!subnetUpdateComplete && subnetRetryCount < MAX_RETRY_COUNT) { - const updatedSubnet = await getSubnetInformation(subnetUrl); - - const endpointEnabled = updatedSubnet.properties.serviceEndpoints && - updatedSubnet.properties.serviceEndpoints.some( - (endpoint: any) => endpoint.service === 'Microsoft.AzureCosmosDB' - ); - - if (endpointEnabled && updatedSubnet.properties.provisioningState === 'Succeeded') { - subnetUpdateComplete = true; - terminal.writeln(terminalLog.success("CosmosDB service endpoint enabled successfully")); - } else { - subnetRetryCount++; - terminal.writeln(terminalLog.progress("Subnet update", `Waiting (${subnetRetryCount}/${MAX_RETRY_COUNT})`)); - await wait(POLLING_INTERVAL_MS); - } - } - - if (!subnetUpdateComplete) { - throw new Error("Failed to enable CosmosDB service endpoint on subnet"); - } - } else { - terminal.writeln(terminalLog.success("CosmosDB service endpoint is already enabled")); - } - } -}; - -/** - * Updates the database account with a new VNet rule - */ -const updateDatabaseWithVNetRule = async (currentDbAccount: any, cloudShellSubnetId: string, dbAccountId: string, terminal: Terminal): Promise => { - // Create a deep copy of the current database account - const updatePayload = JSON.parse(JSON.stringify(currentDbAccount)); - - // Update only the network-related properties - updatePayload.properties.virtualNetworkRules = [ - ...(currentDbAccount.properties.virtualNetworkRules || []), - { id: cloudShellSubnetId, ignoreMissingVNetServiceEndpoint: false } - ]; - updatePayload.properties.isVirtualNetworkFilterEnabled = true; - - // Update the database account - terminal.writeln(terminalLog.subheader("Submitting VNet update request to database")); - await updateDatabaseAccount(dbAccountId, updatePayload); - terminal.writeln(terminalLog.success("Updated Database account with Cloud Shell Vnet")); -}; - -/** - * Monitors the progress of adding a VNet to the database account - */ -const monitorVNetAdditionProgress = async (cloudShellSubnetId: string, dbAccountId: string, terminal: Terminal): Promise => { - let updateComplete = false; - let retryCount = 0; - let lastStatus = ""; - let lastProgress = 0; - let lastOpId = ""; - - terminal.writeln(terminalLog.subheader("Monitoring database update progress")); - - while (!updateComplete && retryCount < MAX_RETRY_COUNT) { - // Check if the VNet is now in the database account - const updatedDbAccount = await getAccountDetails(dbAccountId); - - const isVNetAdded = updatedDbAccount.properties.virtualNetworkRules?.some( - (rule: any) => rule.id === cloudShellSubnetId && (!rule.status || rule.status === 'Succeeded') - ); - - if (isVNetAdded) { - updateComplete = true; - terminal.writeln(terminalLog.success("CloudShell VNet successfully added to database configuration")); - break; - } - - // If not yet added, check for operation progress - const operations = await getDatabaseOperations(dbAccountId); - - // Find network-related operations - const networkOps = operations.value?.filter( - (op: any) => - (op.properties.description?.toLowerCase().includes('network') || - op.properties.description?.toLowerCase().includes('vnet')) - ) || []; - - // Find active operations - const activeOp = networkOps.find((op: any) => op.properties.status === 'InProgress'); - - if (activeOp) { - // Show progress details if available - const currentStatus = activeOp.properties.status; - const progress = activeOp.properties.percentComplete || 0; - const opId = activeOp.name; - - // Only update the terminal if something has changed - if (currentStatus !== lastStatus || progress !== lastProgress || opId !== lastOpId) { - // Create a progress bar - const progressBarLength = 20; - const filledLength = Math.floor(progress / 100 * progressBarLength); - const progressBar = "█".repeat(filledLength) + "░".repeat(progressBarLength - filledLength); - - terminal.writeln(`\x1B[34m [${progressBar}] ${progress}% - ${currentStatus}\x1B[0m`); - lastStatus = currentStatus; - lastProgress = progress; - lastOpId = opId; - } - } else if (networkOps.length > 0) { - // If there are completed operations, show their status - const lastCompletedOp = networkOps[0]; - - if (lastCompletedOp.properties.status !== lastStatus) { - terminal.writeln(terminalLog.progress("Operation status", lastCompletedOp.properties.status)); - lastStatus = lastCompletedOp.properties.status; - } - } - - retryCount++; - await wait(POLLING_INTERVAL_MS); - } - - if (!updateComplete) { - terminal.writeln(terminalLog.warning("Database update timed out. Please check the Azure portal.")); - } -}; - -/** - * Ensures that the CloudShell provider is registered for the current subscription - */ -const ensureCloudShellProviderRegistered = async (terminal: Terminal): Promise => { - try { - terminal.writeln(terminalLog.info("Verifying provider registration...")); - const response: any = await verifyCloudShellProviderRegistration(userContext.subscriptionId); - - if (response.registrationState !== "Registered") { - terminal.writeln(terminalLog.warning("Registering CloudShell provider...")); - await registerCloudShellProvider(userContext.subscriptionId); - terminal.writeln(terminalLog.success("Provider registration successful")); - } - } catch (err) { - terminal.writeln(terminalLog.error("Unable to verify provider registration")); - throw err; - } -}; - -/** - * Retrieves existing VNet settings from user settings if available - */ -const retrieveCloudShellVnetSettings = async (settings: Settings, terminal: Terminal): Promise => { - if (settings?.properties?.vnetSettings && Object.keys(settings.properties.vnetSettings).length > 0) { - try { - const netProfileInfo = await getNetworkProfileInfo(settings.properties.vnetSettings.networkProfileResourceId); - - terminal.writeln(terminalLog.header("Existing Network Configuration")); - - const subnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0] - .properties.ipConfigurations[0].properties.subnet.id; - const vnetResourceId = subnetId.replace(/\/subnets\/[^/]+$/, ''); - - terminal.writeln(terminalLog.item("VNet", vnetResourceId)); - terminal.writeln(terminalLog.item("Subnet", subnetId)); - terminal.writeln(terminalLog.item("Location", settings.properties.vnetSettings.location)); - terminal.writeln(terminalLog.item("Network Profile", settings.properties.vnetSettings.networkProfileResourceId)); - terminal.writeln(terminalLog.item("Relay Namespace", settings.properties.vnetSettings.relayNamespaceResourceId)); - - return { - networkProfileResourceId: settings.properties.vnetSettings.networkProfileResourceId, - relayNamespaceResourceId: settings.properties.vnetSettings.relayNamespaceResourceId, - location: settings.properties.vnetSettings.location - }; - } catch (err) { - terminal.writeln(terminalLog.warning("Error retrieving network profile. Will configure new network.")); - return undefined; - } - } - - return undefined; -}; - -/** - * Determines the appropriate CloudShell region - */ -const determineCloudShellRegion = (terminal: Terminal): { resolvedRegion: string; defaultCloudShellRegion: string } => { - const region = userContext.databaseAccount?.location; - const resolvedRegion = getNormalizedRegion(region, DEFAULT_CLOUDSHELL_REGION); - - return { resolvedRegion, defaultCloudShellRegion: DEFAULT_CLOUDSHELL_REGION }; -}; - -/** - * Configures a new VNet for CloudShell - */ -const configureCloudShellVNet = async (terminal: Terminal, resolvedRegion: string): Promise => { - - // TODO: Use professional and shorter names for resources - const randomSuffix = Math.floor(10000 + Math.random() * 90000); - - const subnetName = `cloudshell-subnet-${randomSuffix}`; - const vnetName = `cloudshell-vnet-${randomSuffix}`; - const networkProfileName = `cloudshell-network-profile-${randomSuffix}`; - const relayName = `cloudshell-relay-${randomSuffix}`; - - terminal.writeln(terminalLog.header("Network Resource Configuration")); - - const azureContainerInstanceOID = await askQuestion( - terminal, - "Enter Azure Container Instance OID (Refer. https://learn.microsoft.com/en-us/azure/cloud-shell/vnet/deployment#get-the-azure-container-instance-id)", - DEFAULT_CONTAINER_INSTANCE_OID - ); - - const vNetSubscriptionId = await askQuestion( - terminal, - "Enter Virtual Network Subscription ID", - userContext.subscriptionId - ); - - const vNetResourceGroup = await askQuestion( - terminal, - "Enter Virtual Network Resource Group", - userContext.resourceGroup - ); - - // Step 1: Create VNet with Subnet - terminal.writeln(terminalLog.header("Deploying Network Resources")); - const vNetConfigPayload = await createCloudShellVnet( - resolvedRegion, - subnetName, - terminal, - vnetName, - vNetSubscriptionId, - vNetResourceGroup - ); - - // Step 2: Create Network Profile - await createNetworkProfileWithVnet( - vNetSubscriptionId, - vNetResourceGroup, - vnetName, - subnetName, - resolvedRegion, - terminal, - networkProfileName - ); - - // Step 3: Create Network Relay - await createNetworkRelay( - resolvedRegion, - terminal, - relayName, - vNetSubscriptionId, - vNetResourceGroup - ); - - // Step 4: Assign Roles - terminal.writeln(terminalLog.header("Configuring Security Permissions")); - await assignRoleToNetworkProfile( - azureContainerInstanceOID, - vNetSubscriptionId, - terminal, - networkProfileName, - vNetResourceGroup - ); - - await assignRoleToRelay( - azureContainerInstanceOID, - vNetSubscriptionId, - terminal, - relayName, - vNetResourceGroup - ); - - // Step 5: Create and return VNet settings - const networkProfileResourceId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName.replace(/[\n\r]/g, "")}`; - const relayResourceId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName.replace(/[\n\r]/g, "")}`; - - terminal.writeln(terminalLog.success("Network configuration complete")); - - return { - networkProfileResourceId, - relayNamespaceResourceId: relayResourceId, - location: vNetConfigPayload.location - }; -}; - -/** - * Creates a VNet for CloudShell - */ -const createCloudShellVnet = async ( - resolvedRegion: string, - subnetName: string, - terminal: Terminal, - vnetName: string, - vNetSubscriptionId: string, - vNetResourceGroup: string -): Promise => { - const vNetConfigPayload = { - location: resolvedRegion, - properties: { - addressSpace: { - addressPrefixes: [DEFAULT_VNET_ADDRESS_PREFIX], - }, - subnets: [ - { - name: subnetName, - properties: { - addressPrefix: DEFAULT_SUBNET_ADDRESS_PREFIX, - delegations: [ - { - name: "CloudShellDelegation", - properties: { - serviceName: "Microsoft.ContainerInstance/containerGroups" - } - } - ], - }, - }, - ], - }, - }; - - terminal.writeln(terminalLog.vnet(`Creating VNet: ${vnetName}`)); - let vNetResponse = await updateVnet( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}`, - vNetConfigPayload - ); - - while (vNetResponse?.properties?.provisioningState !== "Succeeded") { - vNetResponse = await getVnet( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}` - ); - - const vNetState = vNetResponse?.properties?.provisioningState; - if (vNetState !== "Succeeded" && vNetState !== "Failed") { - await wait(POLLING_INTERVAL_MS); - terminal.writeln(terminalLog.progress("VNet deployment", vNetState)); - } else { - break; - } - } - - terminal.writeln(terminalLog.success("VNet created successfully")); - return vNetConfigPayload; -}; - -/** - * Creates a Network Profile for CloudShell - */ -const createNetworkProfileWithVnet = async ( - vNetSubscriptionId: string, - vNetResourceGroup: string, - vnetName: string, - subnetName: string, - resolvedRegion: string, - terminal: Terminal, - networkProfileName: string -): Promise => { - const subnetId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}/subnets/${subnetName}`; - - const createNetworkProfilePayload = { - location: resolvedRegion, - properties: { - containerNetworkInterfaceConfigurations: [ - { - name: 'defaultContainerNicConfig', - properties: { - ipConfigurations: [ - { - name: 'defaultContainerIpConfig', - properties: { - subnet: { - id: subnetId, - } - } - } - ] - } - } - ] - } - }; - - terminal.writeln(terminalLog.vnet("Creating Network Profile")); - let networkProfileResponse = await createNetworkProfile( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}`, - createNetworkProfilePayload - ); - - while (networkProfileResponse?.properties?.provisioningState !== "Succeeded") { - networkProfileResponse = await getNetworkProfileInfo( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}` - ); - - const networkProfileState = networkProfileResponse?.properties?.provisioningState; - if (networkProfileState !== "Succeeded" && networkProfileState !== "Failed") { - await wait(POLLING_INTERVAL_MS); - terminal.writeln(terminalLog.progress("Network Profile", networkProfileState)); - } else { - break; - } - } - - terminal.writeln(terminalLog.success("Network Profile created successfully")); -}; - -/** - * Creates a Network Relay for CloudShell - */ -const createNetworkRelay = async ( - resolvedRegion: string, - terminal: Terminal, - relayName: string, - vNetSubscriptionId: string, - vNetResourceGroup: string -): Promise => { - const relayPayload = { - location: resolvedRegion, - sku: { - name: STANDARD_SKU, - tier: STANDARD_SKU, - } - }; - - terminal.writeln(terminalLog.vnet("Creating Relay Namespace")); - let relayResponse = await createRelay( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}`, - relayPayload - ); - - while (relayResponse?.properties?.provisioningState !== "Succeeded") { - relayResponse = await getRelay( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}` - ); - - const relayState = relayResponse?.properties?.provisioningState; - if (relayState !== "Succeeded" && relayState !== "Failed") { - await wait(POLLING_INTERVAL_MS); - terminal.writeln(terminalLog.progress("Relay Namespace", relayState)); - } else { - break; - } - } - - terminal.writeln(terminalLog.success("Relay Namespace created successfully")); -}; - -/** - * Assigns a role to a Network Profile - */ -const assignRoleToNetworkProfile = async ( - azureContainerInstanceOID: string, - vNetSubscriptionId: string, - terminal: Terminal, - networkProfileName: string, - vNetResourceGroup: string -): Promise => { - const nfRoleName = uuidv4(); - const networkProfileRoleAssignmentPayload = { - properties: { - principalId: azureContainerInstanceOID, - roleDefinitionId: `/subscriptions/${vNetSubscriptionId}/providers/Microsoft.Authorization/roleDefinitions/4d97b98b-1d4f-4787-a291-c67834d212e7` - } - }; - - terminal.writeln(terminalLog.info("Assigning permissions to Network Profile")); - await createRoleOnNetworkProfile( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}/providers/Microsoft.Authorization/roleAssignments/${nfRoleName}`, - networkProfileRoleAssignmentPayload - ); - - terminal.writeln(terminalLog.success("Network Profile permissions assigned")); -}; - -/** - * Assigns a role to a Network Relay - */ -const assignRoleToRelay = async ( - azureContainerInstanceOID: string, - vNetSubscriptionId: string, - terminal: Terminal, - relayName: string, - vNetResourceGroup: string -): Promise => { - const relayRoleName = uuidv4(); - const relayRoleAssignmentPayload = { - properties: { - principalId: azureContainerInstanceOID, - roleDefinitionId: `/subscriptions/${vNetSubscriptionId}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c`, - } - }; - - terminal.writeln(terminalLog.info("Assigning permissions to Relay Namespace")); - await createRoleOnRelay( - `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}/providers/Microsoft.Authorization/roleAssignments/${relayRoleName}`, - relayRoleAssignmentPayload - ); - - terminal.writeln(terminalLog.success("Relay Namespace permissions assigned")); -}; - -/** - * Provisions a CloudShell session - */ -const provisionCloudShellSession = async ( - resolvedRegion: string, - terminal: Terminal, - vNetSettings: object, - isAllPublicAccessEnabled: boolean -): Promise<{ socketUri?: string; provisionConsoleResponse?: any; targetUri?: string }> => { - return new Promise( async (resolve, reject) => { - try { - - terminal.writeln(terminalLog.header("Configuring CloudShell Session")); - // Check if vNetSettings is available and not empty - const hasVNetSettings = vNetSettings && Object.keys(vNetSettings).length > 0; - if (hasVNetSettings) { - const vNetConfig = vNetSettings as VnetSettings; - const networkProfileId = vNetConfig.networkProfileResourceId; - const profileName = networkProfileId.split('/').pop(); - - terminal.writeln(terminalLog.vnet("Enabling private network configuration")); - terminal.writeln(terminalLog.item("Network Profile", profileName)); - - if (vNetConfig.relayNamespaceResourceId) { - const relayName = vNetConfig.relayNamespaceResourceId.split('/').pop(); - terminal.writeln(terminalLog.item("Relay Namespace", relayName)); - } - - terminal.writeln(terminalLog.item("Region", resolvedRegion)); - terminal.writeln(terminalLog.success("CloudShell will use this VNet to connect to your database")); - } - else { - terminal.writeln(terminalLog.warning("No VNet configuration provided")); - terminal.writeln(terminalLog.warning("CloudShell will be provisioned with public network access")); - - if (!isAllPublicAccessEnabled) { - terminal.writeln(terminalLog.error("Warning: Your database has network restrictions")); - terminal.writeln(terminalLog.error("CloudShell may not be able to connect without proper VNet configuration")); - } - } - terminal.writeln(terminalLog.warning("Any previous VNet settings will be overridden")); - - // Apply user settings - await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion, vNetSettings); - terminal.writeln(terminalLog.success("Session settings applied")); - // Provision console - let provisionConsoleResponse; - let attemptCounter = 0; - - do { - provisionConsoleResponse = await provisionConsole(userContext.subscriptionId, resolvedRegion); - terminal.writeln(terminalLog.progress("Provisioning", provisionConsoleResponse.properties.provisioningState)); - - attemptCounter++; - - if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { - await wait(POLLING_INTERVAL_MS); - } - } while (provisionConsoleResponse.properties.provisioningState !== "Succeeded" && attemptCounter < 10); - - if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { - const errorMessage = `Provisioning failed: ${provisionConsoleResponse.properties.provisioningState}`; - terminal.writeln(terminalLog.error(errorMessage)); - return reject(new Error(errorMessage)); - } - // Connect terminal - const connectTerminalResponse = await connectTerminal( - provisionConsoleResponse.properties.uri, - { rows: terminal.rows, cols: terminal.cols } - ); - - const targetUri = `${provisionConsoleResponse.properties.uri}/terminals?cols=${terminal.cols}&rows=${terminal.rows}&version=2019-01-01&shell=bash`; - const termId = connectTerminalResponse.id; - - // Determine socket URI - let socketUri = connectTerminalResponse.socketUri.replace(":443/", ""); - const targetUriBody = targetUri.replace('https://', '').split('?')[0]; - - if (socketUri.indexOf(targetUriBody) === -1) { - socketUri = `wss://${targetUriBody}/${termId}`; - } - - if (targetUriBody.includes('servicebus')) { - const targetUriBodyArr = targetUriBody.split('/'); - socketUri = `wss://${targetUriBodyArr[0]}/$hc/${targetUriBodyArr[1]}/terminals/${termId}`; - } - - return resolve({ socketUri, provisionConsoleResponse, targetUri }); - } catch (err) { - terminal.writeln(terminalLog.error(`Provisioning failed: ${err.message}`)); - return reject(err); - } - }); -}; - -/** - * Asks the user for consent to use the specified CloudShell region - */ -const askForRegionConsent = async (terminal: Terminal, resolvedRegion: string): Promise => { - terminal.writeln(terminalLog.header("CloudShell Region Confirmation")); - terminal.writeln(terminalLog.info("The CloudShell container will be provisioned in a specific Azure region.")); - // Data residency and compliance information - terminal.writeln(terminalLog.subheader("Important Information")); - const dbRegion = userContext.databaseAccount?.location || "unknown"; - terminal.writeln(terminalLog.item("Database Region", dbRegion)); - terminal.writeln(terminalLog.item("CloudShell Container Region", resolvedRegion)); - - terminal.writeln(terminalLog.subheader("What this means to you?")); - terminal.writeln(terminalLog.item("Data Residency", "Commands and query results will be processed in this region")); - terminal.writeln(terminalLog.item("Network", "Database connections will originate from this region")); - - // Consent question - terminal.writeln(""); - terminal.writeln(terminalLog.prompt("Would you like to provision Azure CloudShell in the '" + resolvedRegion + "' region?")); - terminal.writeln(terminalLog.prompt("Press 'Y' to continue or 'N' to cancel (Y/N)")); - - return new Promise((resolve) => { - const keyListener = terminal.onKey(({ key }: { key: string }) => { - keyListener.dispose(); - terminal.writeln(""); - - if (key.toLowerCase() === 'y') { - terminal.writeln(terminalLog.success("Proceeding with CloudShell in " + resolvedRegion)); - terminal.writeln(terminalLog.separator()); - resolve(true); - } else { - terminal.writeln(terminalLog.error("CloudShell provisioning canceled")); - setTimeout(() => terminal.dispose(), 2000); - resolve(false); - } - }); - }); -}; - -/** - * Establishes a terminal connection via WebSocket - */ -const establishTerminalConnection = async ( - terminal: Terminal, - shellType: TerminalKind, - socketUri: string, - provisionConsoleResponse: any, - targetUri: string -): Promise => { - let socket = new WebSocket(socketUri); - - // Get database keys if available - const dbName = userContext.databaseAccount.name; - let keys; - if (dbName) { - keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); - } - - // Configure the socket - const initCommands = getCommands(shellType, keys?.primaryMasterKey); - socket = configureSocketConnection(socket, socketUri, terminal, initCommands, 0); - - // Attach the terminal addon - const attachAddon = new AttachAddon(socket); - terminal.loadAddon(attachAddon); - terminal.writeln(terminalLog.success("Connection established")); - - // Authorize the session - try { - const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri); - const cookieToken = authorizeResponse.token; - - // Load auth token with a hidden image - const img = document.createElement("img"); - img.src = `${targetUri}&token=${encodeURIComponent(cookieToken)}`; - terminal.focus(); - } catch (err) { - terminal.writeln(terminalLog.error("Authorization failed")); - socket.close(); - throw err; - } - - return socket; -}; - -/** - * Configures a WebSocket connection for the terminal - */ -const configureSocketConnection = ( - socket: WebSocket, - uri: string, - terminal: Terminal, - initCommands: string, - socketRetryCount: number -): WebSocket => { - let jsonData = ''; - let keepAliveID: NodeJS.Timeout = null; - let pingCount = 0; - - sendTerminalStartupCommands(socket, initCommands); - - socket.onclose = () => { - if (keepAliveID) { - clearTimeout(keepAliveID); - pingCount = 0; - } - terminal.writeln(terminalLog.warning("Session terminated. Refresh the page to start a new session.")); - }; - - socket.onerror = () => { - if (socketRetryCount < MAX_RETRY_COUNT && socket.readyState !== WebSocket.CLOSED) { - configureSocketConnection(socket, uri, terminal, initCommands, socketRetryCount + 1); - } else { - socket.close(); - } - }; - - socket.onmessage = (event: MessageEvent) => { - pingCount = 0; // Reset ping count on message receipt - - let eventData = ''; - if (typeof event.data === "object") { - try { - const enc = new TextDecoder("utf-8"); - eventData = enc.decode(event.data as any); - } catch (e) { - // Not an array buffer, ignore - } - } - - if (typeof event.data === 'string') { - eventData = event.data; - } - - // Process event data - if (eventData.includes("ie_us") && eventData.includes("ie_ue")) { - const statusData = eventData.split('ie_us')[1].split('ie_ue')[0]; - console.log(statusData); - } else if (eventData.includes("ie_us")) { - jsonData += eventData.split('ie_us')[1]; - } else if (eventData.includes("ie_ue")) { - jsonData += eventData.split('ie_ue')[0]; - console.log(jsonData); - jsonData = ''; - } else if (jsonData.length > 0) { - jsonData += eventData; - } - }; - - return socket; -}; - -/** - * Sends startup commands to the terminal - */ -const sendTerminalStartupCommands = (socket: WebSocket, initCommands: string): void => { - let keepAliveID: NodeJS.Timeout = null; - let pingCount = 0; - - if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(initCommands); - } else { - socket.onopen = () => { - socket.send(initCommands); - - const keepSocketAlive = (socket: WebSocket) => { - if (socket.readyState === WebSocket.OPEN) { - if (pingCount >= MAX_PING_COUNT) { - socket.close(); - } else { - socket.send(''); - pingCount++; - keepAliveID = setTimeout(() => keepSocketAlive(socket), 1000); - } - } - }; - - keepSocketAlive(socket); - }; - } -}; - -/** - * Utility function to ask a question in the terminal - */ -const askQuestion = (terminal: Terminal, question: string, defaultAnswer: string = ""): Promise => { - return new Promise((resolve) => { - const prompt = terminalLog.prompt(`${question} (${defaultAnswer}): `); - terminal.writeln(prompt); - 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(); - if (response.trim() === "") { - response = defaultAnswer; // Use default answer if no input - } - return resolve(response.trim()); - } else if (data === "\u007F" || data === "\b") { // Handle backspace - if (response.length > 0) { - response = response.slice(0, -1); - terminal.write("\x1B[D \x1B[D"); // Move cursor back, clear character - } - } else if (data.charCodeAt(0) >= 32) { // Ignore control characters - response += data; - terminal.write(data); // Display typed characters - } - }); - - // Prevent cursor movement beyond the prompt - terminal.onKey(({ domEvent }: { domEvent: KeyboardEvent }) => { - if (domEvent.key === "ArrowLeft" && response.length === 0) { - domEvent.preventDefault(); // Stop moving left beyond the question - } - }); - }); -}; - -/** - * Utility function to wait for a specified duration - */ -const wait = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); \ No newline at end of file diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx new file mode 100644 index 000000000..e078e2d79 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx @@ -0,0 +1,123 @@ + +import { IDisposable, ITerminalAddon, Terminal } from 'xterm'; + +interface IAttachOptions { + bidirectional?: boolean; +} + +export class AttachAddon implements ITerminalAddon { + private _socket: WebSocket; + private _bidirectional: boolean; + private _disposables: IDisposable[] = []; + private _socketData: string; + + constructor(socket: WebSocket, options?: IAttachOptions) { + this._socket = socket; + // always set binary type to arraybuffer, we do not handle blobs + this._socket.binaryType = 'arraybuffer'; + this._bidirectional = !(options && options.bidirectional === false); + this._socketData = ''; + } + + public activate(terminal: Terminal): void { + this._disposables.push( + addSocketListener(this._socket, 'message', ev => { + let data: ArrayBuffer | string = ev.data; + const startStatusJson = 'ie_us'; + const endStatusJson = 'ie_ue'; + + if (typeof data === 'object') { + const enc = new TextDecoder("utf-8"); + data = enc.decode(ev.data as any); + } + + // for example of json object look in TerminalHelper in the socket.onMessage + if (data.includes(startStatusJson) && data.includes(endStatusJson)) { + // process as one line + const statusData = data.split(startStatusJson)[1].split(endStatusJson)[0]; + data = data.replace(statusData, ''); + data = data.replace(startStatusJson, ''); + data = data.replace(endStatusJson, ''); + } else if (data.includes(startStatusJson)) { + // check for start + const partialStatusData = data.split(startStatusJson)[1]; + this._socketData += partialStatusData; + data = data.replace(partialStatusData, ''); + data = data.replace(startStatusJson, ''); + } else if (data.includes(endStatusJson)) { + // check for end and process the command + const partialStatusData = data.split(endStatusJson)[0]; + this._socketData += partialStatusData; + data = data.replace(partialStatusData, ''); + data = data.replace(endStatusJson, ''); + this._socketData = ''; + } else if (this._socketData.length > 0) { + // check if the line is all data then just concatenate + this._socketData += data; + data = ''; + } + terminal.write(data); + }) + ); + + if (this._bidirectional) { + this._disposables.push(terminal.onData(data => this._sendData(data))); + this._disposables.push(terminal.onBinary(data => this._sendBinary(data))); + } + + this._disposables.push(addSocketListener(this._socket, 'close', () => this.dispose())); + this._disposables.push(addSocketListener(this._socket, 'error', () => this.dispose())); + } + + public dispose(): void { + for (const d of this._disposables) { + d.dispose(); + } + } + + private _sendData(data: string): void { + if (!this._checkOpenSocket()) { + return; + } + this._socket.send(data); + } + + private _sendBinary(data: string): void { + if (!this._checkOpenSocket()) { + return; + } + const buffer = new Uint8Array(data.length); + for (let i = 0; i < data.length; ++i) { + buffer[i] = data.charCodeAt(i) & 255; + } + this._socket.send(buffer); + } + + private _checkOpenSocket(): boolean { + switch (this._socket.readyState) { + case WebSocket.OPEN: + return true; + case WebSocket.CONNECTING: + throw new Error('Attach addon was loaded before socket was open'); + case WebSocket.CLOSING: + return false; + case WebSocket.CLOSED: + throw new Error('Attach addon socket is closed'); + default: + throw new Error('Unexpected socket state'); + } + } +} + +function addSocketListener(socket: WebSocket, type: K, handler: (this: WebSocket, ev: WebSocketEventMap[K]) => any): IDisposable { + socket.addEventListener(type, handler); + return { + dispose: () => { + if (!handler) { + // Already disposed + return; + } + socket.removeEventListener(type, handler); + } + }; +} \ No newline at end of file diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx new file mode 100644 index 000000000..69dc46bd4 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx @@ -0,0 +1,84 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Common utility functions for CloudShell + */ + +import { Terminal } from "xterm"; +import { terminalLog } from "./LogFormatter"; + +/** + * Utility function to wait for a specified duration + */ +export const wait = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Utility function to ask a question in the terminal + */ +export const askQuestion = (terminal: Terminal, question: string, defaultAnswer: string = ""): Promise => { + return new Promise((resolve) => { + const prompt = terminalLog.prompt(`${question} (${defaultAnswer}): `); + terminal.writeln(prompt); + 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(); + if (response.trim() === "") { + response = defaultAnswer; // Use default answer if no input + } + return resolve(response.trim()); + } else if (data === "\u007F" || data === "\b") { // Handle backspace + if (response.length > 0) { + response = response.slice(0, -1); + terminal.write("\x1B[D \x1B[D"); // Move cursor back, clear character + } + } else if (data.charCodeAt(0) >= 32) { // Ignore control characters + response += data; + terminal.write(data); // Display typed characters + } + }); + + // Prevent cursor movement beyond the prompt + terminal.onKey(({ domEvent }: { domEvent: KeyboardEvent }) => { + if (domEvent.key === "ArrowLeft" && response.length === 0) { + domEvent.preventDefault(); // Stop moving left beyond the question + } + }); + }); +}; + +/** + * Utility function to ask for yes/no confirmation + */ +export const askConfirmation = async (terminal: Terminal, question: string): Promise => { + terminal.writeln(""); + terminal.writeln(terminalLog.prompt(`${question} (Y/N)`)); + terminal.focus(); + return new Promise((resolve) => { + const keyListener = terminal.onKey(({ key }: { key: string }) => { + keyListener.dispose(); + terminal.writeln(""); + + if (key.toLowerCase() === 'y') { + resolve(true); + } else { + resolve(false); + } + }); + }); +}; + +/** + * Extract host from a URL + */ +export const getHostFromUrl = (url: string): string => { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch (error) { + console.error("Invalid URL:", error); + return ""; + } +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/LogFormatter.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/LogFormatter.tsx new file mode 100644 index 000000000..130e34389 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/LogFormatter.tsx @@ -0,0 +1,28 @@ +/** + * Standardized terminal logging functions for consistent formatting + */ +export const terminalLog = { + // Section headers + header: (message: string) => `\n\x1B[1;34m┌─ ${message} ${"─".repeat(Math.max(45 - message.length, 0))}\x1B[0m`, + subheader: (message: string) => `\x1B[1;36m├ ${message}\x1B[0m`, + sectionEnd: () => `\x1B[1;34m└${"─".repeat(50)}\x1B[0m\n`, + + // Status messages + success: (message: string) => `\x1B[32m✓ ${message}\x1B[0m`, + warning: (message: string) => `\x1B[33m⚠ ${message}\x1B[0m`, + error: (message: string) => `\x1B[31m✗ ${message}\x1B[0m`, + info: (message: string) => `\x1B[34m${message}\x1B[0m`, + + // Resource information + database: (message: string) => `\x1B[35m🔶 Database: ${message}\x1B[0m`, + vnet: (message: string) => `\x1B[36m🔷 Network: ${message}\x1B[0m`, + cloudshell: (message: string) => `\x1B[32m🔷 CloudShell: ${message}\x1B[0m`, + + // Data formatting + item: (label: string, value: string) => ` • ${label}: \x1B[32m${value}\x1B[0m`, + progress: (operation: string, status: string) => `\x1B[34m${operation}: \x1B[36m${status}\x1B[0m`, + + // User interaction + prompt: (message: string) => `\x1B[1;37m${message}\x1B[0m`, + separator: () => `\x1B[30;1m${"─".repeat(50)}\x1B[0m` +}; diff --git a/src/Explorer/Tabs/Shared/CheckFirewallRules.ts b/src/Explorer/Tabs/Shared/CheckFirewallRules.ts index 33c7f8059..7a5cd54ba 100644 --- a/src/Explorer/Tabs/Shared/CheckFirewallRules.ts +++ b/src/Explorer/Tabs/Shared/CheckFirewallRules.ts @@ -91,7 +91,7 @@ export async function IsPublicAccessAvailable(kind: ViewModels.TerminalKind): Pr ); } - return hasDatabaseNetworkRestrictions(); + return !hasDatabaseNetworkRestrictions(); } /**