From 92e1f830462a1c4d29ffce02392c72c9d1760ac7 Mon Sep 17 00:00:00 2001 From: Kevin Cunnane Date: Fri, 11 Oct 2019 11:06:40 -0700 Subject: [PATCH] Mount HDFS Dialog: basic support (#7580) Implemented in this PR - New base dialog for anything needing to work with the controller. This is important since going from SQL -> Controller we "should" have the right permissions but aren't guaranteed - Support for Mount HDFS via a dialog. Includes basic polling for success/failure, but have to give up after 2.5min as mounting could take hours. By then it's assumed to be successful since server-side has 2min timeout built in. Not implemented in this PR - Script as Notebook button. This should convert the inputs to a set of cells in a notebook so users can run things themselves - Updates based on PM / UX reviews. I think we'll need a round of feedback before completing this work. --- extensions/big-data-cluster/package.json | 15 + extensions/big-data-cluster/package.nls.json | 3 +- .../src/bigDataCluster/constants.ts | 3 + .../controller/clusterControllerApi.ts | 68 ++- .../dialog/addControllerDialog.ts | 3 +- .../bigDataCluster/dialog/mountHdfsDialog.ts | 402 ++++++++++++++++++ .../src/bigDataCluster/utils.ts | 46 +- extensions/big-data-cluster/src/extension.ts | 52 +++ extensions/mssql/package.json | 2 +- 9 files changed, 588 insertions(+), 6 deletions(-) create mode 100644 extensions/big-data-cluster/src/bigDataCluster/dialog/mountHdfsDialog.ts diff --git a/extensions/big-data-cluster/package.json b/extensions/big-data-cluster/package.json index b04b5aed1a..86d56c344d 100644 --- a/extensions/big-data-cluster/package.json +++ b/extensions/big-data-cluster/package.json @@ -45,6 +45,10 @@ { "command": "bigDataClusters.command.manageController", "when": "false" + }, + { + "command": "bigDataClusters.command.mount", + "when": "false" } ], "view/title": [ @@ -70,6 +74,13 @@ "when": "view == sqlBigDataCluster && viewItem == bigDataClusters.itemType.controllerNode", "group": "navigation@3" } + ], + "objectExplorer/item/context": [ + { + "command": "bigDataClusters.command.mount", + "when": "nodeType=~/^mssqlCluster/ && nodeType!=mssqlCluster:message && nodeSubType=~/^(?!:mount).*$/", + "group": "1mssqlCluster@10" + } ] }, "commands": [ @@ -97,6 +108,10 @@ { "command": "bigDataClusters.command.manageController", "title": "%command.manageController.title%" + }, + { + "command": "bigDataClusters.command.mount", + "title": "%command.mount.title%" } ] }, diff --git a/extensions/big-data-cluster/package.nls.json b/extensions/big-data-cluster/package.nls.json index 02f1842318..80563511d0 100644 --- a/extensions/big-data-cluster/package.nls.json +++ b/extensions/big-data-cluster/package.nls.json @@ -4,5 +4,6 @@ "command.addController.title": "Connect to Controller", "command.deleteController.title" : "Delete", "command.refreshController.title" : "Refresh", - "command.manageController.title" : "Manage" + "command.manageController.title" : "Manage", + "command.mount.title" : "Mount HDFS" } diff --git a/extensions/big-data-cluster/src/bigDataCluster/constants.ts b/extensions/big-data-cluster/src/bigDataCluster/constants.ts index 09bd787fc3..b1aea81fd2 100644 --- a/extensions/big-data-cluster/src/bigDataCluster/constants.ts +++ b/extensions/big-data-cluster/src/bigDataCluster/constants.ts @@ -72,3 +72,6 @@ export namespace cssStyles { } export type AuthType = 'integrated' | 'basic'; + +export const clusterEndpointsProperty = 'clusterEndpoints'; +export const controllerEndpointName = 'controller'; diff --git a/extensions/big-data-cluster/src/bigDataCluster/controller/clusterControllerApi.ts b/extensions/big-data-cluster/src/bigDataCluster/controller/clusterControllerApi.ts index 846472d8f2..ac0b147d6d 100644 --- a/extensions/big-data-cluster/src/bigDataCluster/controller/clusterControllerApi.ts +++ b/extensions/big-data-cluster/src/bigDataCluster/controller/clusterControllerApi.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as request from 'request'; + import { authenticateKerberos, getHostAndPortFromEndpoint } from '../auth'; -import { BdcRouterApi, Authentication, EndpointModel, BdcStatusModel } from './apiGenerated'; +import { BdcRouterApi, Authentication, EndpointModel, BdcStatusModel, DefaultApi } from './apiGenerated'; import { TokenRouterApi } from './clusterApiGenerated2'; import { AuthType } from '../constants'; import * as nls from 'vscode-nls'; @@ -73,6 +74,16 @@ class BdcApiWrapper extends BdcRouterApi { this.authentications.default = auth; } } +class DefaultApiWrapper extends DefaultApi { + constructor(basePathOrUsername: string, password: string, basePath: string, auth: Authentication) { + if (password) { + super(basePathOrUsername, password, basePath); + } else { + super(basePath, undefined, undefined); + } + this.authentications.default = auth; + } +} export class ClusterController { @@ -170,6 +181,39 @@ export class ClusterController { throw new ControllerError(error, localize('bdc.error.getBdcStatus', "Error retrieving BDC status from {0}", this._url)); } } + + public async mountHdfs(mountPath: string, remoteUri: string, credentials: {}): Promise { + let auth = await this.authPromise; + const api = new DefaultApiWrapper(this.username, this.password, this._url, auth); + + try { + const mountStatus = await api.createMount('', '', remoteUri, mountPath, credentials); + return { + response: mountStatus.response, + status: mountStatus.body + }; + } catch (error) { + // TODO handle 401 by reauthenticating + throw new ControllerError(error, localize('bdc.error.mountHdfs', "Error creating mount")); + } + } + + public async getMountStatus(mountPath?: string): Promise { + let auth = await this.authPromise; + const api = new DefaultApiWrapper(this.username, this.password, this._url, auth); + + try { + const mountStatus = await api.listMounts('', '', mountPath); + return { + response: mountStatus.response, + mount: mountStatus.body ? JSON.parse(mountStatus.body) : undefined + }; + } catch (error) { + // TODO handle 401 by reauthenticating + throw new ControllerError(error, localize('bdc.error.mountHdfs', "Error creating mount")); + } + } + } /** * Fixes missing protocol and wrong character for port entered by user @@ -203,6 +247,28 @@ export interface IBdcStatusResponse { bdcStatus: BdcStatusModel; } +export enum MountState { + Creating = 'Creating', + Ready = 'Ready', + Error = 'Error' +} + +export interface MountInfo { + mount: string; + remote: string; + state: MountState; + error?: string; +} + +export interface MountResponse { + response: IHttpResponse; + status: any; +} +export interface MountStatusResponse { + response: IHttpResponse; + mount: MountInfo[]; +} + export interface IHttpResponse { method?: string; url?: string; diff --git a/extensions/big-data-cluster/src/bigDataCluster/dialog/addControllerDialog.ts b/extensions/big-data-cluster/src/bigDataCluster/dialog/addControllerDialog.ts index 67978d26f2..ab6dc54c21 100644 --- a/extensions/big-data-cluster/src/bigDataCluster/dialog/addControllerDialog.ts +++ b/extensions/big-data-cluster/src/bigDataCluster/dialog/addControllerDialog.ts @@ -10,7 +10,6 @@ import * as nls from 'vscode-nls'; import { ClusterController, ControllerError } from '../controller/clusterControllerApi'; import { ControllerTreeDataProvider } from '../tree/controllerTreeDataProvider'; import { TreeNode } from '../tree/treeNode'; -import { showErrorMessage } from '../utils'; import { AuthType } from '../constants'; const localize = nls.loadMessageBundle(); @@ -177,7 +176,7 @@ export class AddControllerDialog { ], title: '' }]).withLayout({ width: '100%' }).component(); - + this.onAuthChanged(); await view.initializeModel(formModel); }); diff --git a/extensions/big-data-cluster/src/bigDataCluster/dialog/mountHdfsDialog.ts b/extensions/big-data-cluster/src/bigDataCluster/dialog/mountHdfsDialog.ts new file mode 100644 index 0000000000..35bad4dad4 --- /dev/null +++ b/extensions/big-data-cluster/src/bigDataCluster/dialog/mountHdfsDialog.ts @@ -0,0 +1,402 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; +import * as nls from 'vscode-nls'; +import { ClusterController, ControllerError, MountInfo, MountState } from '../controller/clusterControllerApi'; +import { AuthType } from '../constants'; + +const localize = nls.loadMessageBundle(); + +const basicAuthDisplay = localize('basicAuthName', "Basic"); +const integratedAuthDisplay = localize('integratedAuthName', "Windows Authentication"); + +function getAuthCategory(name: AuthType): azdata.CategoryValue { + if (name === 'basic') { + return { name: name, displayName: basicAuthDisplay }; + } + return { name: name, displayName: integratedAuthDisplay }; +} + +/** + * Converts a comma-delimited set of key value pair credentials to a JSON object. + * This code is taken from the azdata implementation written in Python + */ +function convertCredsToJson(creds: string): { credentials: {} } { + if (!creds) { + return undefined; + } + let credObj = { 'credentials': {} }; + let pairs = creds.split(','); + let validPairs: string[] = []; + for (let i = 0; i < pairs.length; i++) { + // handle escaped commas in a browser-agnostic way using regex: + // this matches a string ending in a single escape character \, but not \\. + // In this case we split on ',` when we should've ignored it as it was a \, instead. + // Restore the escaped comma by combining the 2 split strings + if (i < (pairs.length - 1) && pairs[i].match(/(?!\\).*\\$/)) { + pairs[i + 1] = `${pairs[i]},${pairs[i + 1]}`; + } else { + validPairs.push(pairs[i]); + } + } + + validPairs.forEach(pair => { + const formattingErr = localize('mount.err.formatting', "Bad formatting of credentials at {0}", pair); + try { + // # remove escaped characters for , + pair = pair.replace('\\,', ',').trim(); + let firstEquals = pair.indexOf('='); + if (firstEquals <= 0 || firstEquals >= pair.length) { + throw new Error(formattingErr); + } + let key = pair.substring(0, firstEquals); + let value = pair.substring(firstEquals + 1); + credObj.credentials[key] = value; + } catch (err) { + throw new Error(formattingErr); + } + }); + return credObj; +} + +export interface DialogProperties { + url?: string; + auth?: AuthType; + username?: string; + password?: string; +} + +export interface MountHdfsProperties extends DialogProperties { + hdfsPath?: string; + remoteUri?: string; + credentials?: string; +} + +abstract class HdfsDialogModelBase { + protected _canceled = false; + private _authTypes: azdata.CategoryValue[]; + constructor( + public props: T + ) { + if (!props.auth) { + this.props.auth = 'basic'; + } + } + + public get authCategories(): azdata.CategoryValue[] { + if (!this._authTypes) { + this._authTypes = [getAuthCategory('basic'), getAuthCategory('integrated')]; + } + return this._authTypes; + } + + public get authCategory(): azdata.CategoryValue { + return getAuthCategory(this.props.auth); + } + + public async onComplete(props: T): Promise { + try { + this.props = props; + await this.handleCompleted(); + + } catch (error) { + // Ignore the error if we cancelled the request since we can't stop the actual request from completing + if (!this._canceled) { + throw error; + } + } + } + + protected abstract handleCompleted(): Promise; + + public async onError(error: ControllerError): Promise { + // implement + } + + public async onCancel(): Promise { + this._canceled = true; + } + + protected createController(): ClusterController { + return new ClusterController(this.props.url, this.props.auth, this.props.username, this.props.password, true); + } + +} + +export class MountHdfsDialogModel extends HdfsDialogModelBase { + private credentials: {}; + + constructor(props: MountHdfsProperties) { + super(props); + } + + protected async handleCompleted(): Promise { + if (this.props.auth === 'basic') { + // Verify username and password as we can't make them required in the UI + if (!this.props.username) { + throw new Error(localize('err.controller.username.required', "Username is required")); + } else if (!this.props.password) { + throw new Error(localize('err.controller.password.required', "Password is required")); + } + } + // Validate credentials + this.credentials = convertCredsToJson(this.props.credentials); + + // We pre-fetch the endpoints here to verify that the information entered is correct (the user is able to connect) + let controller = this.createController(); + let response = await controller.getEndPoints(); + if (response && response.endPoints) { + if (this._canceled) { + return; + } + // + azdata.tasks.startBackgroundOperation( + { + connection: undefined, + displayName: localize('mount.task.name', "Mounting HDFS folder on path {0}", this.props.hdfsPath), + description: '', + isCancelable: false, + operation: op => { + this.onSubmit(controller, op); + } + } + ); + } + } + + private async onSubmit(controller: ClusterController, op: azdata.BackgroundOperation): Promise { + try { + await controller.mountHdfs(this.props.hdfsPath, this.props.remoteUri, this.credentials); + op.updateStatus(azdata.TaskStatus.InProgress, localize('mount.task.submitted', "Mount creation has started")); + + // Wait until status has changed or some sensible time expired. If it goes over 2 minutes we assume it's "working" + // as there's no other API that'll give us this for now + let result = await this.waitOnMountStatusChange(controller); + let msg = result.state === MountState.Ready ? localize('mount.task.complete', "Mounting HDFS folder is complete") + : localize('mount.task.inprogress', "Mounting is likely to complete, check back later to verify"); + op.updateStatus(azdata.TaskStatus.Succeeded, msg); + } catch (error) { + const errMsg = localize('mount.task.error', "Error mounting folder: {0}", (error instanceof Error ? error.message : error)); + vscode.window.showErrorMessage(errMsg); + op.updateStatus(azdata.TaskStatus.Failed, errMsg); + } + } + + private waitOnMountStatusChange(controller: ClusterController): Promise { + return new Promise((resolve, reject) => { + const waitTime = 5 * 1000; // 5 seconds + const maxRetries = 30; // 5 x 30 = 150 seconds. After this time, can assume things are "working" as 2 min timeout passed + let waitOnChange = async (retries: number) => { + try { + let mountInfo = await this.getMountStatus(controller, this.props.hdfsPath); + if (mountInfo && mountInfo.error || mountInfo.state === MountState.Error) { + reject(new Error(mountInfo.error ? mountInfo.error : localize('mount.error.unknown', "Unknown error occurred during the mount process"))); + } else if (mountInfo.state === MountState.Ready || retries <= 0) { + resolve(mountInfo); + } else { + setTimeout(() => { + waitOnChange(retries - 1).catch(e => reject(e)); + }, waitTime); + } + } catch (err) { + reject(err); + } + }; + waitOnChange(maxRetries); + }); + } + + private async getMountStatus(controller: ClusterController, path: string): Promise { + let statusResponse = await controller.getMountStatus(path); + if (statusResponse.mount) { + return Array.isArray(statusResponse.mount) ? statusResponse.mount[0] : statusResponse.mount; + } + return undefined; + } +} + +abstract class HdfsDialogBase { + + protected dialog: azdata.window.Dialog; + protected uiModelBuilder!: azdata.ModelBuilder; + + protected urlInputBox!: azdata.InputBoxComponent; + protected authDropdown!: azdata.DropDownComponent; + protected usernameInputBox!: azdata.InputBoxComponent; + protected passwordInputBox!: azdata.InputBoxComponent; + + constructor(private title: string, protected model: HdfsDialogModelBase) { + } + + public showDialog(): void { + this.createDialog(); + azdata.window.openDialog(this.dialog); + } + + private createDialog(): void { + this.dialog = azdata.window.createModelViewDialog(this.title); + this.dialog.registerContent(async view => { + this.uiModelBuilder = view.modelBuilder; + + this.urlInputBox = this.uiModelBuilder.inputBox() + .withProperties({ + placeHolder: localize('textUrlLower', "url"), + value: this.model.props.url, + }).component(); + this.urlInputBox.enabled = false; + + this.authDropdown = this.uiModelBuilder.dropDown().withProperties({ + values: this.model.authCategories, + value: this.model.authCategory, + editable: false, + }).component(); + this.authDropdown.onValueChanged(e => this.onAuthChanged()); + this.usernameInputBox = this.uiModelBuilder.inputBox() + .withProperties({ + placeHolder: localize('textUsernameLower', "username"), + value: this.model.props.username + }).component(); + this.passwordInputBox = this.uiModelBuilder.inputBox() + .withProperties({ + placeHolder: localize('textPasswordLower', "password"), + inputType: 'password', + value: this.model.props.password + }) + .component(); + + let connectionSection: azdata.FormComponentGroup = { + components: [ + { + component: this.urlInputBox, + title: localize('textUrlCapital', "URL"), + required: true + }, { + component: this.authDropdown, + title: localize('textAuthCapital', "Authentication type"), + required: true + }, { + component: this.usernameInputBox, + title: localize('textUsernameCapital', "Username"), + required: false + }, { + component: this.passwordInputBox, + title: localize('textPasswordCapital', "Password"), + required: false + } + ], + title: localize('hdsf.dialog.connection.section', "Cluster Connection") + }; + let formModel = this.uiModelBuilder.formContainer() + .withFormItems([ + this.getMainSection(), + connectionSection + ]).withLayout({ width: '100%' }).component(); + this.onAuthChanged(); + + await view.initializeModel(formModel); + }); + + this.dialog.registerCloseValidator(async () => await this.validate()); + this.dialog.cancelButton.onClick(async () => await this.cancel()); + this.dialog.okButton.label = localize('hdfs.dialog.ok', "OK"); + this.dialog.cancelButton.label = localize('hdfs.dialog.cancel', "Cancel"); + } + + protected abstract getMainSection(): azdata.FormComponentGroup; + + protected get authValue(): AuthType { + return (this.authDropdown.value).name as AuthType; + } + + private onAuthChanged(): void { + let isBasic = this.authValue === 'basic'; + this.usernameInputBox.enabled = isBasic; + this.passwordInputBox.enabled = isBasic; + if (!isBasic) { + this.usernameInputBox.value = ''; + this.passwordInputBox.value = ''; + } + } + + protected abstract validate(): Promise; + + private async cancel(): Promise { + if (this.model && this.model.onCancel) { + await this.model.onCancel(); + } + } +} +export class MountHdfsDialog extends HdfsDialogBase { + private pathInputBox: azdata.InputBoxComponent; + private remoteUriInputBox: azdata.InputBoxComponent; + private credentialsInputBox: azdata.InputBoxComponent; + + constructor(model: MountHdfsDialogModel) { + super(localize('mount.dialog.title', "Mount HDFS Folder"), model); + } + + protected getMainSection(): azdata.FormComponentGroup { + this.pathInputBox = this.uiModelBuilder.inputBox() + .withProperties({ + value: this.model.props.hdfsPath + }).component(); + this.remoteUriInputBox = this.uiModelBuilder.inputBox() + .withProperties({ + value: this.model.props.remoteUri + }) + .component(); + this.credentialsInputBox = this.uiModelBuilder.inputBox() + .withProperties({ + inputType: 'password', + value: this.model.props.credentials + }) + .component(); + + return { + components: [ + { + component: this.pathInputBox, + title: localize('mount.hdfsPath', "HDFS Path"), + required: true + }, { + component: this.remoteUriInputBox, + title: localize('mount.remoteUri', "Remote URI"), + required: true + }, { + component: this.credentialsInputBox, + title: localize('mount.credentials', "Credentials"), + required: false + } + ], + title: localize('mount.main.section', "Mount Configuration") + }; + } + + protected async validate(): Promise { + try { + await this.model.onComplete({ + url: this.urlInputBox && this.urlInputBox.value, + auth: this.authValue, + username: this.usernameInputBox && this.usernameInputBox.value, + password: this.passwordInputBox && this.passwordInputBox.value, + hdfsPath: this.pathInputBox && this.pathInputBox.value, + remoteUri: this.remoteUriInputBox && this.remoteUriInputBox.value, + credentials: this.credentialsInputBox && this.credentialsInputBox.value + }); + return true; + } catch (error) { + this.dialog.message = { + text: (typeof error === 'string') ? error : error.message, + level: azdata.window.MessageLevel.Error + }; + if (this.model && this.model.onError) { + await this.model.onError(error as ControllerError); + } + return false; + } + } +} diff --git a/extensions/big-data-cluster/src/bigDataCluster/utils.ts b/extensions/big-data-cluster/src/bigDataCluster/utils.ts index 573b1a2541..d656461a17 100644 --- a/extensions/big-data-cluster/src/bigDataCluster/utils.ts +++ b/extensions/big-data-cluster/src/bigDataCluster/utils.ts @@ -6,9 +6,10 @@ 'use strict'; import * as vscode from 'vscode'; +import * as azdata from 'azdata'; import * as nls from 'vscode-nls'; - const localize = nls.loadMessageBundle(); +import * as constants from './constants'; export enum Endpoint { gateway = 'gateway', @@ -210,3 +211,46 @@ export function getHealthStatusDot(healthStatus?: string): string { return '•'; } } + + +interface RawEndpoint { + serviceName: string; + description?: string; + endpoint?: string; + protocol?: string; + ipAddress?: string; + port?: number; +} + +export interface IEndpoint { + serviceName: string; + description: string; + endpoint: string; + protocol: string; +} + +export function getClusterEndpoints(serverInfo: azdata.ServerInfo): IEndpoint[] { + let endpoints: RawEndpoint[] = serverInfo.options[constants.clusterEndpointsProperty]; + if (!endpoints || endpoints.length === 0) { return []; } + + return endpoints.map(e => { + // If endpoint is missing, we're on CTP bits. All endpoints from the CTP serverInfo should be treated as HTTPS + let endpoint = e.endpoint ? e.endpoint : `https://${e.ipAddress}:${e.port}`; + let updatedEndpoint: IEndpoint = { + serviceName: e.serviceName, + description: e.description, + endpoint: endpoint, + protocol: e.protocol + }; + return updatedEndpoint; + }); +} + +export function getControllerEndpoint(serverInfo: azdata.ServerInfo): string | undefined { + let endpoints = getClusterEndpoints(serverInfo); + if (endpoints) { + let index = endpoints.findIndex(ep => ep.serviceName.toLowerCase() === constants.controllerEndpointName.toLowerCase()); + if (index < 0) { return undefined; } + return endpoints[index].endpoint; + } +} diff --git a/extensions/big-data-cluster/src/extension.ts b/extensions/big-data-cluster/src/extension.ts index 1a24dd4e66..d08d2e8918 100644 --- a/extensions/big-data-cluster/src/extension.ts +++ b/extensions/big-data-cluster/src/extension.ts @@ -6,6 +6,7 @@ 'use strict'; import * as vscode from 'vscode'; +import * as azdata from 'azdata'; import * as nls from 'vscode-nls'; import { ControllerTreeDataProvider } from './bigDataCluster/tree/controllerTreeDataProvider'; import { IconPathHelper } from './bigDataCluster/constants'; @@ -14,6 +15,8 @@ import { AddControllerDialogModel, AddControllerDialog } from './bigDataCluster/ import { ControllerNode } from './bigDataCluster/tree/controllerTreeNode'; import { BdcDashboard } from './bigDataCluster/dialog/bdcDashboard'; import { BdcDashboardModel } from './bigDataCluster/dialog/bdcDashboardModel'; +import { MountHdfsDialogModel, MountHdfsProperties, MountHdfsDialog } from './bigDataCluster/dialog/mountHdfsDialog'; +import { getControllerEndpoint } from './bigDataCluster/utils'; const localize = nls.loadMessageBundle(); @@ -21,6 +24,9 @@ const AddControllerCommand = 'bigDataClusters.command.addController'; const DeleteControllerCommand = 'bigDataClusters.command.deleteController'; const RefreshControllerCommand = 'bigDataClusters.command.refreshController'; const ManageControllerCommand = 'bigDataClusters.command.manageController'; +const MountHdfsCommand = 'bigDataClusters.command.mount'; + +const endpointNotFoundError = localize('mount.error.endpointNotFound', "Controller endpoint information was not found"); let throttleTimers: { [key: string]: any } = {}; @@ -59,6 +65,52 @@ function registerCommands(context: vscode.ExtensionContext, treeDataProvider: Co const dashboard: BdcDashboard = new BdcDashboard(title, new BdcDashboardModel(node.url, node.auth, node.username, node.password)); dashboard.showDashboard(); }); + + vscode.commands.registerCommand(MountHdfsCommand, e => mountHdfs(e).catch(error => { + vscode.window.showErrorMessage(error instanceof Error ? error.message : error); + })); +} + +async function mountHdfs(explorerContext?: azdata.ObjectExplorerContext): Promise { + let endpoint = await lookupController(explorerContext); + if (!endpoint) { + vscode.window.showErrorMessage(endpointNotFoundError); + return; + } + let profile = explorerContext.connectionProfile; + let mountProps: MountHdfsProperties = { + url: endpoint, + auth: profile.authenticationType === 'SqlLogin' ? 'basic' : 'integrated', + username: profile.userName, + password: profile.password, + hdfsPath: getHdsfPath(explorerContext.nodeInfo.nodePath) + }; + let dialog = new MountHdfsDialog(new MountHdfsDialogModel(mountProps)); + dialog.showDialog(); +} + +function getHdsfPath(nodePath: string): string { + const hdfsNodeLabel = '/HDFS'; + let index = nodePath.indexOf(hdfsNodeLabel); + if (index >= 0) { + let subPath = nodePath.substring(index + hdfsNodeLabel.length); + return subPath.length > 0 ? subPath : '/'; + } + // Use the root + return '/'; +} + +async function lookupController(explorerContext?: azdata.ObjectExplorerContext): Promise { + if (!explorerContext) { + return undefined; + } + + let serverInfo = await azdata.connection.getServerInfo(explorerContext.connectionProfile.id); + if (!serverInfo || !serverInfo.options) { + vscode.window.showErrorMessage(endpointNotFoundError); + return undefined; + } + return getControllerEndpoint(serverInfo); } function addBdcController(treeDataProvider: ControllerTreeDataProvider, node?: TreeNode): void { diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index 55f1a481c3..08b993e092 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -284,7 +284,7 @@ { "command": "mssqlCluster.livy.cmd.submitSparkJob", "when": "nodeType == mssqlCluster:hdfs", - "group": "1root@1" + "group": "1mssqlCluster@7" }, { "command": "mssqlCluster.livy.cmd.submitFileToSparkJob",