diff --git a/src/Explorer/Tabs/CloudShellTab/AttachAddOn.tsx b/src/Explorer/Tabs/CloudShellTab/AttachAddOn.tsx new file mode 100644 index 000000000..5ebd8808c --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/AttachAddOn.tsx @@ -0,0 +1,126 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + */ + +import { IDisposable, ITerminalAddon, Terminal } from 'xterm'; + +interface IAttachOptions { + bidirectional?: boolean; +} + +export class AttachAddon implements ITerminalAddon { + private _socket: WebSocket; + private _bidirectional: boolean; + private _disposables: IDisposable[] = []; + private _socketData: string; + + constructor(socket: WebSocket, options?: IAttachOptions) { + this._socket = socket; + // always set binary type to arraybuffer, we do not handle blobs + this._socket.binaryType = 'arraybuffer'; + this._bidirectional = !(options && options.bidirectional === false); + this._socketData = ''; + } + + public activate(terminal: Terminal): void { + this._disposables.push( + addSocketListener(this._socket, 'message', ev => { + let data: ArrayBuffer | string = ev.data; + const startStatusJson = 'ie_us'; + const endStatusJson = 'ie_ue'; + + if (typeof data === 'object') { + const enc = new TextDecoder("utf-8"); + data = enc.decode(ev.data as any); + } + + // for example of json object look in TerminalHelper in the socket.onMessage + if (data.includes(startStatusJson) && data.includes(endStatusJson)) { + // process as one line + const statusData = data.split(startStatusJson)[1].split(endStatusJson)[0]; + data = data.replace(statusData, ''); + data = data.replace(startStatusJson, ''); + data = data.replace(endStatusJson, ''); + } else if (data.includes(startStatusJson)) { + // check for start + const partialStatusData = data.split(startStatusJson)[1]; + this._socketData += partialStatusData; + data = data.replace(partialStatusData, ''); + data = data.replace(startStatusJson, ''); + } else if (data.includes(endStatusJson)) { + // check for end and process the command + const partialStatusData = data.split(endStatusJson)[0]; + this._socketData += partialStatusData; + data = data.replace(partialStatusData, ''); + data = data.replace(endStatusJson, ''); + this._socketData = ''; + } else if (this._socketData.length > 0) { + // check if the line is all data then just concatenate + this._socketData += data; + data = ''; + } + terminal.write(data); + }) + ); + + if (this._bidirectional) { + this._disposables.push(terminal.onData(data => this._sendData(data))); + this._disposables.push(terminal.onBinary(data => this._sendBinary(data))); + } + + this._disposables.push(addSocketListener(this._socket, 'close', () => this.dispose())); + this._disposables.push(addSocketListener(this._socket, 'error', () => this.dispose())); + } + + public dispose(): void { + for (const d of this._disposables) { + d.dispose(); + } + } + + private _sendData(data: string): void { + if (!this._checkOpenSocket()) { + return; + } + this._socket.send(data); + } + + private _sendBinary(data: string): void { + if (!this._checkOpenSocket()) { + return; + } + const buffer = new Uint8Array(data.length); + for (let i = 0; i < data.length; ++i) { + buffer[i] = data.charCodeAt(i) & 255; + } + this._socket.send(buffer); + } + + private _checkOpenSocket(): boolean { + switch (this._socket.readyState) { + case WebSocket.OPEN: + return true; + case WebSocket.CONNECTING: + throw new Error('Attach addon was loaded before socket was open'); + case WebSocket.CLOSING: + return false; + case WebSocket.CLOSED: + throw new Error('Attach addon socket is closed'); + default: + throw new Error('Unexpected socket state'); + } + } +} + +function addSocketListener(socket: WebSocket, type: K, handler: (this: WebSocket, ev: WebSocketEventMap[K]) => any): IDisposable { + socket.addEventListener(type, handler); + return { + dispose: () => { + if (!handler) { + // Already disposed + return; + } + socket.removeEventListener(type, handler); + } + }; +} \ No newline at end of file diff --git a/src/Explorer/Tabs/CloudShellTab/Data.tsx b/src/Explorer/Tabs/CloudShellTab/Data.tsx index ca239f6b2..38217bd71 100644 --- a/src/Explorer/Tabs/CloudShellTab/Data.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Data.tsx @@ -1,3 +1,7 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + */ + import { v4 as uuidv4 } from 'uuid'; import { configContext } from "../../../ConfigContext"; import { armRequest } from "../../../Utils/arm/request"; @@ -23,10 +27,10 @@ export const trackedApiCall = , U>(apiCall: (...args: T) => }; }; -export const getUserRegion = trackedApiCall(async (subscriptionId: string) => { +export const getUserRegion = trackedApiCall(async (subscriptionId: string, resourceGroup: string, accountName: string) => { return await armRequest({ host: configContext.ARM_ENDPOINT, - path: `/subscriptions/${subscriptionId}/locations`, + path: `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`, method: "GET", apiVersion: "2022-12-01" }); @@ -158,3 +162,19 @@ 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", "eastus2euap", "centraluseuap"]); +const defaultCloudshellRegion = "westus"; + +export const getNormalizedRegion = (region: string) => { + if (!region) return defaultCloudshellRegion; + + const regionMap: Record = { + "centralus": "centraluseuap", + "eastus2": "eastus2euap" + }; + + const normalizedRegion = regionMap[region.toLowerCase()] || region; + return validCloudShellRegions.has(normalizedRegion.toLowerCase()) ? normalizedRegion : defaultCloudshellRegion; + }; + diff --git a/src/Explorer/Tabs/CloudShellTab/DataModels.tsx b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx index 68aaf580f..de825a084 100644 --- a/src/Explorer/Tabs/CloudShellTab/DataModels.tsx +++ b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx @@ -1,3 +1,7 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + */ + export const enum OsType { Linux = "linux", Windows = "windows" diff --git a/src/Explorer/Tabs/CloudShellTab/LogFormatter.tsx b/src/Explorer/Tabs/CloudShellTab/LogFormatter.tsx new file mode 100644 index 000000000..e537c8459 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/LogFormatter.tsx @@ -0,0 +1,11 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + */ + +export const LogError = (message: string) => { + return `\n\r\x1B[1;37m${message}`; +} + +export const LogInfo = (message: string) => { + return `\x1B[1;37m${message}`; +} \ No newline at end of file diff --git a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx index a62cf94a6..f418a2778 100644 --- a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx +++ b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx @@ -1,15 +1,14 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + */ + import { Terminal } from "xterm"; import { userContext } from "../../../UserContext"; -import { authorizeSession, connectTerminal, getUserRegion, getUserSettings, provisionConsole, putEphemeralUserSettings, registerCloudShellProvider, validateUserSettings, verifyCloudshellProviderRegistration } from "./Data"; +import { AttachAddon } from "./AttachAddOn"; +import { authorizeSession, connectTerminal, getNormalizedRegion, getUserSettings, provisionConsole, putEphemeralUserSettings, registerCloudShellProvider, validateUserSettings, verifyCloudshellProviderRegistration } from "./Data"; +import { LogError, LogInfo } from "./LogFormatter"; -export const startCloudShellterminal = async (xterminal: Terminal, intervalsToClearRef: any, authorizationToken: any) => { - - const tokenInterval = setInterval(async () => { - authorizationToken - }, 1000 * 60 * 10); - - const intervalsToClear = intervalsToClearRef.current ?? []; - intervalsToClear.push(tokenInterval); +export const startCloudShellterminal = async (xterminal: Terminal, initCommands: string, authorizationToken: any) => { // validate that the subscription id is registered in the Cloudshell namespace try { @@ -18,39 +17,21 @@ export const startCloudShellterminal = async (xterminal: Terminal, intervalsToCl await registerCloudShellProvider(userContext.subscriptionId); } } catch (err) { - xterminal.writeln(''); - xterminal.writeln('Unable to verify cloudshell provider registration.'); - intervalsToClear.forEach((val) => window.clearInterval(+val)); + xterminal.writeln(LogError('Unable to verify cloudshell provider registration.')); throw err; } - const region = await getUserRegion(userContext.subscriptionId).then((res) => { - const reqId = (res.headers as any).get("x-ms-routing-request-id"); - const location = reqId?.split(":")?.[0]?.toLowerCase() ?? ""; - const validRegions = new Set(["westus", "southcentralus", "eastus", "northeurope", "westeurope", "centralindia", "southeastasia", "westcentralus", "eastus2euap", "centraluseuap"]); - if (validRegions.has(location.toLowerCase())) { - return location; - } - if (location === "centralus") { - return "centraluseuap"; - } - if (location === "eastus2") { - return "eastus2euap"; - } - return "westus"; - }).catch((err) => { - xterminal.writeln(''); - xterminal.writeln('Unable to get user region.'); - return "westus"; - }); - - xterminal.writeln('Requested Region ' + region); + const region = userContext.databaseAccount?.location; + xterminal.writeln(LogInfo(`Database Acount Region identified as '${region}'`)); + + const resolvedRegion = getNormalizedRegion(region); + xterminal.writeln(LogInfo(`Requesting Cloudshell instance at '${resolvedRegion}'`)); try { // do not use the subscription from the preferred settings use the one from the context - await putEphemeralUserSettings(userContext.subscriptionId, region); + await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion); } catch (err) { - xterminal.writeln('Unable to update user settings to ephemeral session.'); + xterminal.writeln(LogError('Unable to update user settings to ephemeral session.')); throw err; } @@ -62,34 +43,33 @@ export const startCloudShellterminal = async (xterminal: Terminal, intervalsToCl throw new Error("Invalid user settings detected for ephemeral session."); } } catch (err) { - xterminal.writeln('Unable to verify user settings for ephemeral session.'); + xterminal.writeln(LogError('Unable to verify user settings for ephemeral session.')); throw err; } // trigger callback to provision console internal let provisionConsoleResponse; try { - provisionConsoleResponse = await provisionConsole(userContext.subscriptionId, region); - // statusPaneUpdateCommands.setTerminalUri(provisionConsoleResponse.properties.uri); + provisionConsoleResponse = await provisionConsole(userContext.subscriptionId, resolvedRegion); } catch (err) { - xterminal.writeln('Unable to provision console.'); + xterminal.writeln(LogError('Unable to provision console.\n\r')); throw err; } if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { - xterminal.writeln("Failed to provision console."); + xterminal.writeln(LogError("Failed to provision console.\n\r")); throw new Error("Failed to provision console."); } - xterminal.writeln("Connecting to cloudshell..."); - xterminal.writeln("Please wait..."); + xterminal.writeln(LogInfo("Connecting to cloudshell")); + xterminal.writeln(LogInfo("Please wait...")); // connect the terminal let connectTerminalResponse; try { connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, { rows: xterminal.rows, cols: xterminal.cols }); } catch (err) { xterminal.writeln(''); - xterminal.writeln('Unable to connect terminal.'); + xterminal.writeln(LogError('Unable to connect terminal.')); throw err; } @@ -106,16 +86,12 @@ export const startCloudShellterminal = async (xterminal: Terminal, intervalsToCl socketUri = 'wss://' + targetUriBodyArr[0] + '/$hc/' + targetUriBodyArr[1] + '/terminals/' + termId; } - // // provision appropriate first party permissions to cloudshell instance - // await postTokens(provisionConsoleResponse.properties.uri, authorizationToken).catch((err) => { - // xterminal.writeln('Unable to provision first party permissions to cloudshell instance.'); - // intervalsToClear.forEach((val) => window.clearInterval(+val)); - // throw err; - // }); - const socket = new WebSocket(socketUri); - configureSocket(socket, socketUri, xterminal, intervalsToClear, 0); + configureSocket(socket, socketUri, xterminal, initCommands, 0); + + const attachAddon = new AttachAddon(socket); + xterminal.loadAddon(attachAddon); // authorize the session try { @@ -124,12 +100,12 @@ export const startCloudShellterminal = async (xterminal: Terminal, intervalsToCl const a = document.createElement("img"); a.src = targetUri + "&token=" + encodeURIComponent(cookieToken); } catch (err) { - xterminal.writeln('Unable to authroize the session'); + xterminal.writeln(LogError('Unable to authroize the session')); socket.close(); throw err; } - xterminal.writeln("Connected to cloudshell."); + xterminal.writeln(LogInfo("Connection Successful!!!")); xterminal.focus(); return socket; @@ -138,21 +114,10 @@ export const startCloudShellterminal = async (xterminal: Terminal, intervalsToCl let keepAliveID: NodeJS.Timeout = null; let pingCount = 0; -export const configureSocket = (socket: WebSocket, uri: string, terminal: any, intervals: NodeJS.Timer[], socketRetryCount: number) => { +export const configureSocket = (socket: WebSocket, uri: string, terminal: any, initCommands: string, socketRetryCount: number) => { let jsonData = ''; socket.onopen = () => { - terminal.writeln("Socket Opened"); - const initializeCommand = - `curl -s https://ipinfo.io \n` + - `curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz \n` + - `tar -xvzf mongosh-2.3.8-linux-x64.tgz \n` + - `mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/ \n` + - `echo 'export PATH=$PATH:$HOME/mongosh/bin' >> ~/.bashrc \n` + - `source ~/.bashrc \n` + - `mongosh --version \n`; - - terminal.writeln(initializeCommand); - socket.send(initializeCommand); + socket.send(initCommands); const keepSocketAlive = (socket: WebSocket) => { if (socket.readyState === WebSocket.OPEN) { @@ -169,32 +134,24 @@ export const configureSocket = (socket: WebSocket, uri: string, terminal: any, }; socket.onclose = () => { - terminal.writeln("Socket Closed"); if (keepAliveID) { clearTimeout(keepAliveID); pingCount = 0; } - intervals.forEach((val) => { - window.clearInterval(+val); - }); terminal.writeln("Session terminated. Please refresh the page to start a new session."); }; socket.onerror = () => { - terminal.writeln("terminal reconnected"); if (socketRetryCount < 10 && socket.readyState !== WebSocket.CLOSED) { - configureSocket(socket, uri, terminal, intervals, socketRetryCount + 1); + configureSocket(socket, uri, terminal, initCommands, socketRetryCount + 1); } else { - // log an error indicating socket connection failed - terminal.writeln("Socket connection closed"); // close the socket socket.close(); } }; socket.onmessage = (event: MessageEvent) => { - terminal.writeln("Socket onMessage"); // if we are sending and receiving messages the terminal is not idle set ping count to 0 pingCount = 0; @@ -210,8 +167,6 @@ export const configureSocket = (socket: WebSocket, uri: string, terminal: any, } if (typeof event.data === 'string') { eventData = event.data; - - terminal.write(eventData); } // process as one line or process as multiline diff --git a/src/Explorer/Tabs/CloudShellTerminalComponent.tsx b/src/Explorer/Tabs/CloudShellTerminalComponent.tsx index 94255d9f0..65a7e8ba4 100644 --- a/src/Explorer/Tabs/CloudShellTerminalComponent.tsx +++ b/src/Explorer/Tabs/CloudShellTerminalComponent.tsx @@ -1,15 +1,20 @@ import React, { useEffect, useRef } from "react"; import { Terminal } from "xterm"; import "xterm/css/xterm.css"; +import { TerminalKind } from "../../Contracts/ViewModels"; import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; import { startCloudShellterminal } from "./CloudShellTab/UseTerminal"; +export interface CloudShellTerminalProps { + shellType: TerminalKind; +} -export const CloudShellTerminalComponent: React.FC = () => { +export const CloudShellTerminalComponent: React.FC = ({ + shellType +}: CloudShellTerminalProps) => { const terminalRef = useRef(null); // Reference for terminal container const xtermRef = useRef(null); // Reference for XTerm instance const socketRef = useRef(null); // Reference for WebSocket - const intervalsToClearRef = useRef([]); useEffect(() => { // Initialize XTerm instance @@ -26,7 +31,7 @@ export const CloudShellTerminalComponent: React.FC = () => { } const authorizationHeader = getAuthorizationHeader() - socketRef.current = startCloudShellterminal(term, intervalsToClearRef, authorizationHeader.token); + socketRef.current = startCloudShellterminal(term, getCommands(shellType), authorizationHeader.token); term.onData((data) => { if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { @@ -46,3 +51,36 @@ export const CloudShellTerminalComponent: React.FC = () => { return
; }; + +export const getCommands = (terminalKind: TerminalKind): string => { + switch (terminalKind) { + case TerminalKind.Postgres: + return `curl -s https://ipinfo.io \n` + + `curl -LO https://ftp.postgresql.org/pub/source/v15.2/postgresql-15.2.tar.bz2 \n` + + `tar -xvjf postgresql-15.2.tar.bz2 \n` + + `cd postgresql-15.2 \n` + + `mkdir ~/pgsql \n` + + `curl -LO https://ftp.gnu.org/gnu/readline/readline-8.1.tar.gz \n` + + `tar -xvzf readline-8.1.tar.gz \n` + + `cd readline-8.1 \n` + + `./configure --prefix=$HOME/pgsql \n`; + case TerminalKind.Mongo || terminalKind === TerminalKind.VCoreMongo: + return `curl -s https://ipinfo.io \n` + + `curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz \n` + + `tar -xvzf mongosh-2.3.8-linux-x64.tgz \n` + + `mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/ \n` + + `echo 'export PATH=$PATH:$HOME/mongosh/bin' >> ~/.bashrc \n` + + `source ~/.bashrc \n` + + `mongosh --version \n`; + case TerminalKind.Cassandra: + return `curl -s https://ipinfo.io \n` + + `curl -OL http://apache.mirror.digitalpacific.com.au/cassandra/4.0.0/apache-cassandra-4.0.0-bin.tar.gz \n` + + `tar -xvzf apache-cassandra-4.0.0-bin.tar.gz \n` + + `cd apache-cassandra-4.0.0 \n` + + `mkdir ~/cassandra \n` + + `echo 'export CASSANDRA_HOME=$HOME/cassandra' >> ~/.bashrc \n` + + `source ~/.bashrc \n`; + default: + throw new Error("Unsupported terminal kind"); + } +} \ No newline at end of file diff --git a/src/Explorer/Tabs/TerminalTab.tsx b/src/Explorer/Tabs/TerminalTab.tsx index 993d5c113..b88d26cc5 100644 --- a/src/Explorer/Tabs/TerminalTab.tsx +++ b/src/Explorer/Tabs/TerminalTab.tsx @@ -49,17 +49,13 @@ class NotebookTerminalComponentAdapter implements ReactAdapter { /> ); } - return this.parameters() ? - ( userContext.features.enableCloudShell ? ( - - ) : ( + return this.parameters() ? ( - ) ): ( + />): ( ); } @@ -72,9 +68,6 @@ class CloudShellTerminalComponentAdapter implements ReactAdapter { // parameters: true: show, false: hide public parameters: ko.Computed; constructor( - private getDatabaseAccount: () => DataModels.DatabaseAccount, - private getTabId: () => string, - private getUsername: () => string, private isAllPublicIPAddressesEnabled: ko.Observable, private kind: ViewModels.TerminalKind, ) {} @@ -90,7 +83,8 @@ class CloudShellTerminalComponentAdapter implements ReactAdapter { ); } return this.parameters() ? ( - + ) : ( ); @@ -144,9 +138,6 @@ export default class TerminalTab extends TabsBase { private initializeNotebookTerminalAdapter(options: TerminalTabOptions): void { if (userContext.features.enableCloudShell) { this.terminalComponentAdapter = new CloudShellTerminalComponentAdapter( - () => userContext?.databaseAccount, - () => this.tabId, - () => this.getUsername(), this.isAllPublicIPAddressesEnabled, options.kind );