mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-04-18 07:38:43 +01:00
refactored code
This commit is contained in:
parent
80edb66fbf
commit
a7e38201c4
@ -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<CloudShellTerminalProps> = ({
|
||||
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<CloudShellTerminalProps> = ({
|
||||
term.dispose(); // Clean up XTerm instance
|
||||
};
|
||||
|
||||
}, []);
|
||||
}, [shellType]);
|
||||
|
||||
return <div ref={terminalRef} style={{ width: "100%", height: "500px"}} />;
|
||||
};
|
||||
|
393
src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx
Normal file
393
src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx
Normal file
@ -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<void> => {
|
||||
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<boolean> => {
|
||||
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<boolean>((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<WebSocket> => {
|
||||
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<string>) => {
|
||||
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);
|
||||
};
|
||||
}
|
||||
};
|
263
src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx
Normal file
263
src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx
Normal file
@ -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<void> => {
|
||||
await armRequest<void>({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
|
||||
method: "DELETE",
|
||||
apiVersion: "2023-02-01-preview"
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserSettings = async (): Promise<Settings> => {
|
||||
const resp = await armRequest<any>({
|
||||
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<ProvisionConsoleResponse> => {
|
||||
const data = {
|
||||
properties: {
|
||||
osType: OsType.Linux
|
||||
}
|
||||
};
|
||||
|
||||
return await armRequest<any>({
|
||||
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<ConnectTerminalResponse> => {
|
||||
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<Authorization> => {
|
||||
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<T>(networkProfileResourceId: string, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
|
||||
return await GetARMCall<T>(networkProfileResourceId, apiVersion);
|
||||
}
|
||||
|
||||
export async function getAccountDetails<T>(databaseAccountId: string, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
|
||||
return await GetARMCall<T>(databaseAccountId, apiVersion);
|
||||
}
|
||||
|
||||
export async function getVnetInformation<T>(vnetId: string, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
|
||||
return await GetARMCall<T>(vnetId, apiVersion);
|
||||
}
|
||||
|
||||
export async function getSubnetInformation<T>(subnetId: string, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET);
|
||||
return await GetARMCall<T>(subnetId, apiVersion);
|
||||
}
|
||||
|
||||
export async function updateSubnetInformation<T>(subnetId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET);
|
||||
return await PutARMCall<T>(subnetId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function updateDatabaseAccount<T>(accountId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
|
||||
return await PutARMCall<T>(accountId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function getDatabaseOperations<T>(accountId: string, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
|
||||
return await GetARMCall<T>(`${accountId}/operations`, apiVersion);
|
||||
}
|
||||
|
||||
export async function updateVnet<T>(vnetId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
|
||||
return await PutARMCall<T>(vnetId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function getVnet<T>(vnetId: string, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
|
||||
return await GetARMCall<T>(vnetId, apiVersion);
|
||||
}
|
||||
|
||||
export async function createNetworkProfile<T>(networkProfileId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
|
||||
return await PutARMCall<T>(networkProfileId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function createRelay<T>(relayId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY);
|
||||
return await PutARMCall<T>(relayId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function getRelay<T>(relayId: string, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY);
|
||||
return await GetARMCall<T>(relayId, apiVersion);
|
||||
}
|
||||
|
||||
export async function createRoleOnNetworkProfile<T>(roleId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE);
|
||||
return await PutARMCall<T>(roleId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function createRoleOnRelay<T>(roleId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE);
|
||||
return await PutARMCall<T>(roleId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function createPrivateEndpoint<T>(privateEndpointId: string, request: object, apiVersionOverride?: string): Promise<T> {
|
||||
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
|
||||
return await PutARMCall<T>(privateEndpointId, request, apiVersion);
|
||||
}
|
||||
|
||||
export async function GetARMCall<T>(path: string, apiVersion: string = '2024-07-01'): Promise<T> {
|
||||
return await armRequest<T>({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: path,
|
||||
method: "GET",
|
||||
apiVersion: apiVersion
|
||||
});
|
||||
}
|
||||
|
||||
export async function PutARMCall<T>(path: string, request: object, apiVersion: string = '2024-07-01'): Promise<T> {
|
||||
return await armRequest<T>({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: path,
|
||||
method: "PUT",
|
||||
apiVersion: apiVersion,
|
||||
body: request
|
||||
});
|
||||
}
|
12
src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx
Normal file
12
src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx
Normal file
@ -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');
|
||||
};
|
37
src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx
Normal file
37
src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx
Normal file
@ -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<string, string> = {
|
||||
"centralus": "westcentralus",
|
||||
"eastus2": "eastus"
|
||||
};
|
||||
|
||||
const normalizedRegion = regionMap[region.toLowerCase()] || region;
|
||||
return validCloudShellRegions.has(normalizedRegion.toLowerCase()) ? normalizedRegion : defaultCloudshellRegion;
|
||||
};
|
74
src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts
Normal file
74
src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts
Normal file
@ -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<ResourceType, string>;
|
||||
SHELL_TYPES: Record<TerminalKind, Record<ResourceType, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
163
src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx
Normal file
163
src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx
Normal file
@ -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<string, string>;
|
||||
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<string, string>;
|
||||
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"
|
||||
}
|
94
src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx
Normal file
94
src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx
Normal file
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
894
src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx
Normal file
894
src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx
Normal file
@ -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<VnetSettings> {
|
||||
if (settings?.properties?.vnetSettings && Object.keys(settings.properties.vnetSettings).length > 0) {
|
||||
try {
|
||||
const netProfileInfo = await getNetworkProfileInfo<any>(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<boolean> {
|
||||
// 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<boolean> {
|
||||
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<any>(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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
// 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<void> {
|
||||
// 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<any>(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<boolean> {
|
||||
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<any>(cloudShellVnetId);
|
||||
subnetInfo = await getSubnetInformation<any>(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<any>(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<void> {
|
||||
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<any>(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<void> {
|
||||
// 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<void> {
|
||||
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<any>(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<any>(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<VnetSettings> {
|
||||
// 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<any> {
|
||||
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<any>(
|
||||
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}`,
|
||||
vNetConfigPayload
|
||||
);
|
||||
|
||||
while (vNetResponse?.properties?.provisioningState !== "Succeeded") {
|
||||
vNetResponse = await getVnet<any>(
|
||||
`/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<void> {
|
||||
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<any>(
|
||||
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}`,
|
||||
createNetworkProfilePayload
|
||||
);
|
||||
|
||||
while (networkProfileResponse?.properties?.provisioningState !== "Succeeded") {
|
||||
networkProfileResponse = await getNetworkProfileInfo<any>(
|
||||
`/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<void> {
|
||||
const relayPayload = {
|
||||
location: resolvedRegion,
|
||||
sku: {
|
||||
name: STANDARD_SKU,
|
||||
tier: STANDARD_SKU,
|
||||
}
|
||||
};
|
||||
|
||||
terminal.writeln(terminalLog.vnet("Creating Relay Namespace"));
|
||||
let relayResponse = await createRelay<any>(
|
||||
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}`,
|
||||
relayPayload
|
||||
);
|
||||
|
||||
while (relayResponse?.properties?.provisioningState !== "Succeeded") {
|
||||
relayResponse = await getRelay<any>(
|
||||
`/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<void> {
|
||||
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<any>(
|
||||
`/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<void> {
|
||||
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<any>(
|
||||
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}/providers/Microsoft.Authorization/roleAssignments/${relayRoleName}`,
|
||||
relayRoleAssignmentPayload
|
||||
);
|
||||
|
||||
terminal.writeln(terminalLog.success("Relay Namespace permissions assigned"));
|
||||
}
|
||||
}
|
@ -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<string> {
|
||||
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`
|
||||
];
|
||||
}
|
||||
}
|
@ -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<string> {
|
||||
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`
|
||||
];
|
||||
}
|
||||
}
|
@ -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<string> {
|
||||
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'`
|
||||
];
|
||||
}
|
||||
}
|
@ -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<string>;
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
}
|
@ -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<string> {
|
||||
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`
|
||||
];
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
123
src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx
Normal file
123
src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx
Normal file
@ -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<K extends keyof WebSocketEventMap>(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);
|
||||
}
|
||||
};
|
||||
}
|
84
src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx
Normal file
84
src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx
Normal file
@ -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<void> => 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<string> => {
|
||||
return new Promise<string>((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<boolean> => {
|
||||
terminal.writeln("");
|
||||
terminal.writeln(terminalLog.prompt(`${question} (Y/N)`));
|
||||
terminal.focus();
|
||||
return new Promise<boolean>((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 "";
|
||||
}
|
||||
};
|
28
src/Explorer/Tabs/CloudShellTab/Utils/LogFormatter.tsx
Normal file
28
src/Explorer/Tabs/CloudShellTab/Utils/LogFormatter.tsx
Normal file
@ -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`
|
||||
};
|
@ -91,7 +91,7 @@ export async function IsPublicAccessAvailable(kind: ViewModels.TerminalKind): Pr
|
||||
);
|
||||
}
|
||||
|
||||
return hasDatabaseNetworkRestrictions();
|
||||
return !hasDatabaseNetworkRestrictions();
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
x
Reference in New Issue
Block a user