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.
This commit is contained in:
Kevin Cunnane
2019-10-11 11:06:40 -07:00
committed by GitHub
parent 9a3f72591e
commit 92e1f83046
9 changed files with 588 additions and 6 deletions

View File

@@ -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%"
}
]
},

View File

@@ -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"
}

View File

@@ -72,3 +72,6 @@ export namespace cssStyles {
}
export type AuthType = 'integrated' | 'basic';
export const clusterEndpointsProperty = 'clusterEndpoints';
export const controllerEndpointName = 'controller';

View File

@@ -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<MountResponse> {
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<MountStatusResponse> {
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;

View File

@@ -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);
});

View File

@@ -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<T extends DialogProperties> {
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<void> {
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<void>;
public async onError(error: ControllerError): Promise<void> {
// implement
}
public async onCancel(): Promise<void> {
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<MountHdfsProperties> {
private credentials: {};
constructor(props: MountHdfsProperties) {
super(props);
}
protected async handleCompleted(): Promise<void> {
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<void> {
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<MountInfo> {
return new Promise<MountInfo>((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<MountInfo> {
let statusResponse = await controller.getMountStatus(path);
if (statusResponse.mount) {
return Array.isArray(statusResponse.mount) ? statusResponse.mount[0] : statusResponse.mount;
}
return undefined;
}
}
abstract class HdfsDialogBase<T extends DialogProperties> {
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<T>) {
}
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<azdata.InputBoxProperties>({
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<azdata.InputBoxProperties>({
placeHolder: localize('textUsernameLower', "username"),
value: this.model.props.username
}).component();
this.passwordInputBox = this.uiModelBuilder.inputBox()
.withProperties<azdata.InputBoxProperties>({
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 (<azdata.CategoryValue>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<boolean>;
private async cancel(): Promise<void> {
if (this.model && this.model.onCancel) {
await this.model.onCancel();
}
}
}
export class MountHdfsDialog extends HdfsDialogBase<MountHdfsProperties> {
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<azdata.InputBoxProperties>({
value: this.model.props.hdfsPath
}).component();
this.remoteUriInputBox = this.uiModelBuilder.inputBox()
.withProperties<azdata.InputBoxProperties>({
value: this.model.props.remoteUri
})
.component();
this.credentialsInputBox = this.uiModelBuilder.inputBox()
.withProperties<azdata.InputBoxProperties>({
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<boolean> {
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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<void> {
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<string | undefined> {
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 {

View File

@@ -284,7 +284,7 @@
{
"command": "mssqlCluster.livy.cmd.submitSparkJob",
"when": "nodeType == mssqlCluster:hdfs",
"group": "1root@1"
"group": "1mssqlCluster@7"
},
{
"command": "mssqlCluster.livy.cmd.submitFileToSparkJob",