From 4dd6db57ee5e01841d84eed805a59c1b4fab1cd1 Mon Sep 17 00:00:00 2001 From: Arvind Ranasaria Date: Fri, 18 Oct 2019 23:17:21 -0700 Subject: [PATCH] Feat/tool install master merge back to master (#7819) * add install tools button (#7454) * add install tools button * address comments * remove description for install tools hint message * First working version of AutoDeployment of tools (#7647) First working version of AutoDeployment of tools. This pull request adds feature to install the tools needed for doing BDC/TINA deployments. This has been tested so far only on win32 and testing on other platforms is in progress. * removing TODO and redundant code * Not localizing azuredatastudio product name * convert methods returning Promises to async-await * changing from null to undefined * Localize all the command labels * using existing sudo-prompt typings * progres/error status in ModalDialogue && PR fixes * review feedback to change warning to information * revert settings.json changes * fix resource-Deployment Extension Unit Test * ensuring platform service's working directory * incorporate review feedback * review feedback * addressing PR feedback * PR fixes * PR Feedback * remove debug logs * disable UI deployment containers when installing * addding data type to stdout/stderr messaging * remove commented code * revert accidental change * addressing review feedback * fix failed install with zero exit code * fixing bug due to typo * fixes for linux * Misc fixes during mac testing * PR fixes --- build/lib/extensions.js | 3 +- build/lib/extensions.ts | 4 +- extensions/resource-deployment/package.json | 10 +- .../resource-deployment/src/interfaces.ts | 47 +++- .../src/services/azdataService.ts | 4 +- .../src/services/platformService.ts | 204 ++++++++++++-- .../src/services/tools/azCliTool.ts | 110 +++++++- .../src/services/tools/azdataTool.ts | 90 ++++++- .../src/services/tools/dockerTool.ts | 21 +- .../src/services/tools/kubeCtlTool.ts | 104 +++++++- .../src/services/tools/toolBase.ts | 252 +++++++++++++++--- .../src/test/toolsService.test.ts | 2 +- .../resource-deployment/src/typings/ref.d.ts | 3 +- .../src/ui/resourceTypePickerDialog.ts | 132 +++++++-- .../resource-deployment/src/ui/wizardBase.ts | 8 +- extensions/resource-deployment/src/utils.ts | 6 +- extensions/resource-deployment/yarn.lock | 24 ++ src/typings/sudo-prompt.d.ts | 10 +- 18 files changed, 893 insertions(+), 141 deletions(-) diff --git a/build/lib/extensions.js b/build/lib/extensions.js index bfae2bf319..875daea9fc 100644 --- a/build/lib/extensions.js +++ b/build/lib/extensions.js @@ -200,7 +200,8 @@ const sqlBuiltInExtensions = [ 'dacpac', 'schema-compare', 'cms', - 'query-history' + 'query-history', + 'resource-deployment' ]; const builtInExtensions = process.env['VSCODE_QUALITY'] === 'stable' ? require('../builtInExtensions.json') : require('../builtInExtensions-insiders.json'); // {{SQL CARBON EDIT}} - End diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 081e0b6fb3..e4a37253b2 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -230,13 +230,15 @@ const sqlBuiltInExtensions = [ // the extension will be excluded from SQLOps package and will have separate vsix packages 'admin-tool-ext-win', 'agent', + 'big-data-cluster', 'import', 'profiler', 'admin-pack', 'dacpac', 'schema-compare', 'cms', - 'query-history' + 'query-history', + 'resource-deployment' ]; interface IBuiltInExtension { diff --git a/extensions/resource-deployment/package.json b/extensions/resource-deployment/package.json index 8f33b346ed..6ddcce5a85 100644 --- a/extensions/resource-deployment/package.json +++ b/extensions/resource-deployment/package.json @@ -235,7 +235,7 @@ "name": "kubectl" }, { - "name": "azcli" + "name": "azure-cli" }, { "name": "azdata" @@ -338,14 +338,16 @@ ] }, "dependencies": { + "promisify-child-process": "^3.1.1", + "sudo-prompt": "^9.0.0", + "vscode": "^1.1.26", "vscode-nls": "^4.0.0", "yamljs": "^0.3.0" }, "devDependencies": { - "typemoq": "^2.1.0", - "vscode": "^1.1.26", "@types/yamljs": "0.2.30", "mocha-junit-reporter": "^1.17.0", - "mocha-multi-reporters": "^1.1.7" + "mocha-multi-reporters": "^1.1.7", + "typemoq": "^2.1.0" } } diff --git a/extensions/resource-deployment/src/interfaces.ts b/extensions/resource-deployment/src/interfaces.ts index ebf4926e18..5f50aea6db 100644 --- a/extensions/resource-deployment/src/interfaces.ts +++ b/extensions/resource-deployment/src/interfaces.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; -import { SemVer } from 'semver'; +import * as vscode from 'vscode'; export const NoteBookEnvironmentVariablePrefix = 'AZDATA_NB_VAR_'; @@ -168,12 +168,12 @@ export interface FieldInfo { editable?: boolean; // for editable dropdown } -export enum LabelPosition { +export const enum LabelPosition { Top = 'top', Left = 'left' } -export enum FontStyle { +export const enum FontStyle { Normal = 'normal', Italic = 'italic' } @@ -200,6 +200,13 @@ export interface NotebookInfo { linux: string; } +export enum OsType { + win32 = 'win32', + darwin = 'darwin', + linux = 'linux', + others = 'others' +} + export interface ToolRequirementInfo { name: string; version: string; @@ -212,20 +219,46 @@ export enum ToolType { Azdata } +export const enum ToolStatus { + NotInstalled = 'NotInstalled', + Installed = 'Installed', + Installing = 'Installing', + Error = 'Error', + Failed = 'Failed' +} + export interface ITool { + isInstalling: any; readonly name: string; readonly displayName: string; readonly description: string; readonly type: ToolType; - readonly version: SemVer | undefined; readonly homePage: string; - readonly isInstalled: boolean; - loadInformation(): Promise; + readonly displayStatus: string; readonly statusDescription: string | undefined; + readonly autoInstallSupported: boolean; + readonly autoInstallRequired: boolean; + readonly isNotInstalled: boolean; + readonly needsInstallation: boolean; + readonly outputChannelName: string; + readonly fullVersion: string | undefined; + readonly onDidUpdateData: vscode.Event; + showOutputChannel(preserveFocus?: boolean): void; + loadInformation(): Promise; + install(): Promise; } -export enum BdcDeploymentType { +export const enum BdcDeploymentType { NewAKS = 'new-aks', ExistingAKS = 'existing-aks', ExistingKubeAdm = 'existing-kubeadm' } + +export interface Command { + command: string; + sudo?: boolean; + comment?: string; + workingDirectory?: string; + additionalEnvironmentVariables?: NodeJS.ProcessEnv; + ignoreError?: boolean; +} diff --git a/extensions/resource-deployment/src/services/azdataService.ts b/extensions/resource-deployment/src/services/azdataService.ts index 99bd7f83de..cbbb663085 100644 --- a/extensions/resource-deployment/src/services/azdataService.ts +++ b/extensions/resource-deployment/src/services/azdataService.ts @@ -66,9 +66,7 @@ export class AzdataService implements IAzdataService { } private async ensureWorkingDirectoryExists(): Promise { - if (! await this.platformService.fileExists(this.platformService.storagePath())) { - await this.platformService.makeDirectory(this.platformService.storagePath()); - } + await this.platformService.ensureDirectoryExists(this.platformService.storagePath()); } private async getJsonObjectFromFile(path: string): Promise { diff --git a/extensions/resource-deployment/src/services/platformService.ts b/extensions/resource-deployment/src/services/platformService.ts index 37c8a6cc42..5a31f38790 100644 --- a/extensions/resource-deployment/src/services/platformService.ts +++ b/extensions/resource-deployment/src/services/platformService.ts @@ -2,37 +2,61 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import * as fs from 'fs'; -import * as vscode from 'vscode'; import * as azdata from 'azdata'; -import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as cp from 'promisify-child-process'; +import * as sudo from 'sudo-prompt'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import { OsType } from '../interfaces'; import { getErrorMessage } from '../utils'; +const localize = nls.loadMessageBundle(); +const extensionOutputChannel = localize('resourceDeployment.outputChannel', "Deployments"); +const sudoPromptTitle = 'AzureDataStudio'; /** * Abstract of platform dependencies */ export interface IPlatformService { + osType(): OsType; platform(): string; storagePath(): string; copyFile(source: string, target: string): Promise; fileExists(file: string): Promise; openFile(filePath: string): void; - showErrorMessage(message: string): void; + showErrorMessage(error: Error | string): void; + logToOutputChannel(data: string | Buffer, header?: string): void; + outputChannelName(): string; + showOutputChannel(preserveFocus?: boolean): void; isNotebookNameUsed(title: string): boolean; makeDirectory(path: string): Promise; + ensureDirectoryExists(directory: string): Promise; readTextFile(filePath: string): Promise; runCommand(command: string, options?: CommandOptions): Promise; saveTextFile(content: string, path: string): Promise; deleteFile(path: string, ignoreError?: boolean): Promise; } +interface CommandOutput { + stdout: string; + stderr: string; +} + export interface CommandOptions { workingDirectory?: string; additionalEnvironmentVariables?: NodeJS.ProcessEnv; + sudo?: boolean; + commandTitle?: string; + ignoreError?: boolean; } +/** + * A class that provides various services to interact with the platform on which the code runs + */ export class PlatformService implements IPlatformService { + + private _outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel(extensionOutputChannel); + constructor(private _storagePath: string = '') { } @@ -44,57 +68,185 @@ export class PlatformService implements IPlatformService { return process.platform; } - copyFile(source: string, target: string): Promise { - return fs.promises.copyFile(source, target); + outputChannelName(): string { + return this._outputChannel.name; } - fileExists(file: string): Promise { - return fs.promises.access(file).then(() => { + showOutputChannel(preserveFocus?: boolean): void { + this._outputChannel.show(preserveFocus); + } + + osType(platform: string = this.platform()): OsType { + if (Object.values(OsType).includes(platform)) { + return platform; + } else { + return OsType.others; + } + } + + async copyFile(source: string, target: string): Promise { + return await fs.promises.copyFile(source, target); + } + + async fileExists(file: string): Promise { + try { + await fs.promises.access(file); return true; - }).catch(error => { + } catch (error) { if (error && error.code === 'ENOENT') { return false; } throw error; - }); + } } openFile(filePath: string): void { vscode.commands.executeCommand('vscode.open', vscode.Uri.file(filePath)); } - showErrorMessage(message: string): void { - vscode.window.showErrorMessage(message); + showErrorMessage(error: Error | string): void { + vscode.window.showErrorMessage(getErrorMessage(error)); } isNotebookNameUsed(title: string): boolean { return (azdata.nb.notebookDocuments.findIndex(doc => doc.isUntitled && doc.fileName === title) > -1); } - makeDirectory(path: string): Promise { - return fs.promises.mkdir(path); + async makeDirectory(path: string): Promise { + await fs.promises.mkdir(path); } - readTextFile(filePath: string): Promise { - return fs.promises.readFile(filePath, 'utf8'); + /** + *This function ensures that the given {@link directory} does not exist it creates it. It creates only the most leaf folder so if any ancestor folders are missing then this command throws an error. + * @param directory - the path to ensure + */ + async ensureDirectoryExists(directory: string): Promise { + if (!await this.fileExists(directory)) { + await this.makeDirectory(directory); + } } - runCommand(command: string, options?: CommandOptions): Promise { - return new Promise((resolve, reject) => { - const env = Object.assign({}, process.env, options && options.additionalEnvironmentVariables); - cp.exec(command, { - cwd: options && options.workingDirectory, - env: env - }, (error, stdout, stderror) => { + async readTextFile(filePath: string): Promise { + return await fs.promises.readFile(filePath, 'utf8'); + } + + public logToOutputChannel(data: string | Buffer, header?: string): void { + //input data is localized by caller + data.toString().split(/\r?\n/) + .forEach(line => { + this._outputChannel.appendLine(header ? header + line : line); + }); + } + + private outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChannel, header: string): void { + data.toString().split(/\r?\n/) + .forEach(line => { + outputChannel.appendLine(header + line); + }); + } + + async runCommand(command: string, options?: CommandOptions): Promise { + if (options && options.commandTitle !== undefined && options.commandTitle !== null) { + this._outputChannel.appendLine(`\t[ ${options.commandTitle} ]`); // commandTitle inputs are localized by caller + } + + try { + if (options && options.sudo) { + return await this.runSudoCommand(command, this._outputChannel, options); + } else { + return await this.runStreamedCommand(command, this._outputChannel, options); + } + } catch (error) { + this._outputChannel.append(localize('platformService.RunCommand.ErroredOut', "\t>>> {0} ... errored out: {1}", command, getErrorMessage(error))); //errors are localized in our code where emitted, other errors are pass through from external components that are not easily localized + if (!(options && options.ignoreError)) { + throw error; + } else { + this._outputChannel.append(localize('platformService.RunCommand.IgnoringError', "\t>>> Ignoring error in execution and continuing tool deployment")); + return ''; + } + } + } + + private sudoExec(command: string, options: sudo.SudoOptions): Promise { + return new Promise((resolve, reject) => { + sudo.exec(command, options, (error, stdout, stderr) => { if (error) { reject(error); } else { - resolve(stdout); + resolve({ stdout, stderr }); } }); }); } + private async runSudoCommand(command: string, outputChannel: vscode.OutputChannel, options?: CommandOptions): Promise { + outputChannel.appendLine(` sudo> ${command}`); + + if (options && options.workingDirectory) { + process.chdir(options.workingDirectory); + } + + // Workaround for https://github.com/jorangreef/sudo-prompt/issues/111 + // DevNote: The environment variable being excluded from getting passed to sudo will never exist on a 'unixy' box. So this affects windows only. + // On my testing on windows machine for our usage the environment variables being excluded were not important for the process execution being used here. + // If one is trying to use this code elsewhere, one should test on windows thoroughly unless the above issue is fixed. + const origEnv: NodeJS.ProcessEnv = Object.assign({}, process.env, options && options.additionalEnvironmentVariables); + const env: NodeJS.ProcessEnv = {}; + + Object.keys(origEnv).filter(key => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)).forEach((key) => { + env[key] = origEnv[key]; + }); + // Workaround for https://github.com/jorangreef/sudo-prompt/issues/111 done + + const sudoOptions = { + name: sudoPromptTitle, + env: env + }; + + try { + const { stdout, stderr } = await this.sudoExec(command, sudoOptions); + this.outputDataChunk(stdout, outputChannel, localize('platformService.RunCommand.stdout', " stdout: ")); + this.outputDataChunk(stderr, outputChannel, localize('platformService.RunCommand.stderr', " stderr: ")); + return stdout; + } catch (error) { + this.outputDataChunk(error, outputChannel, localize('platformService.RunCommand.stderr', " stderr: ")); + throw error; + } + } + + private async runStreamedCommand(command: string, outputChannel: vscode.OutputChannel, options?: CommandOptions): Promise { + const stdoutData: string[] = []; + outputChannel.appendLine(` > ${command}`); + + const spawnOptions = { + cwd: options && options.workingDirectory, + env: Object.assign({}, process.env, options && options.additionalEnvironmentVariables), + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, // 10 Mb of output can be captured. + shell: true, + detached: false, + windowsHide: true + }; + const child = cp.spawn(command, [], spawnOptions); + + // Add listeners to print stdout and stderr and exit code + child.on('exit', (code: number | null, signal: string | null) => { + if (code !== null) { + outputChannel.appendLine(localize('platformService.RunStreamedCommand.ExitedWithCode', " >>> ${0} ... exited with code: ${1}", command, code)); + } else { + outputChannel.appendLine(localize('platformService.RunStreamedCommand.ExitedWithSignal', " >>> ${0} ... exited with signal: ${1}", command, signal)); + } + }); + child.stdout.on('data', (data: string | Buffer) => { + stdoutData.push(data.toString()); + this.outputDataChunk(data, outputChannel, localize('platformService.RunCommand.stdout', " stdout: ")); + }); + child.stderr.on('data', (data: string | Buffer) => { this.outputDataChunk(data, outputChannel, localize('platformService.RunCommand.stderr', " stderr: ")); }); + + await child; + return stdoutData.join(''); + } + saveTextFile(content: string, path: string): Promise { return fs.promises.writeFile(path, content, 'utf8'); } @@ -108,7 +260,7 @@ export class PlatformService implements IPlatformService { } catch (error) { if (ignoreError) { - console.error('Error occured deleting file: ', getErrorMessage(error)); + console.error('Error occurred deleting file: ', getErrorMessage(error)); } else { throw error; } diff --git a/extensions/resource-deployment/src/services/tools/azCliTool.ts b/extensions/resource-deployment/src/services/tools/azCliTool.ts index ee0325319f..f1ad15a7bd 100644 --- a/extensions/resource-deployment/src/services/tools/azCliTool.ts +++ b/extensions/resource-deployment/src/services/tools/azCliTool.ts @@ -2,14 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import { ToolType } from '../../interfaces'; -import * as nls from 'vscode-nls'; -import { SemVer } from 'semver'; -import { IPlatformService } from '../platformService'; import { EOL } from 'os'; +import { SemVer } from 'semver'; +import * as nls from 'vscode-nls'; +import { Command, OsType, ToolType } from '../../interfaces'; +import { IPlatformService } from '../platformService'; import { ToolBase } from './toolBase'; + const localize = nls.loadMessageBundle(); +const defaultInstallationRoot = '~/.local/bin'; +const win32InstallationRoot = `${process.env['ProgramFiles(x86)']}\\Microsoft SDKs\\Azure\\CLI2\\wbin`; export class AzCliTool extends ToolBase { constructor(platformService: IPlatformService) { @@ -17,11 +19,11 @@ export class AzCliTool extends ToolBase { } get name(): string { - return 'azcli'; + return 'azure-cli'; } get description(): string { - return localize('resourceDeployment.AzCLIDescription', 'A command-line tool for managing Azure resources'); + return localize('resourceDeployment.AzCLIDescription', "A command-line tool for managing Azure resources"); } get type(): ToolType { @@ -29,13 +31,33 @@ export class AzCliTool extends ToolBase { } get displayName(): string { - return localize('resourceDeployment.AzCLIDisplayName', 'Azure CLI'); + return localize('resourceDeployment.AzCLIDisplayName', "Azure CLI"); } get homePage(): string { return 'https://docs.microsoft.com/cli/azure/install-azure-cli'; } + get autoInstallSupported(): boolean { + return true; + } + + protected async getInstallationPath(): Promise { + switch (this.osType) { + case OsType.win32: + return win32InstallationRoot; + default: + return defaultInstallationRoot; + } + } + + readonly allInstallationCommands: Map = new Map([ + [OsType.linux, linuxInstallationCommands], + [OsType.win32, win32InstallationCommands], + [OsType.darwin, macOsInstallationCommands], + [OsType.others, defaultInstallationCommands] + ]); + protected getVersionFromOutput(output: string): SemVer | undefined { if (output && output.includes('azure-cli')) { return new SemVer(output.split(EOL)[0].replace('azure-cli', '').replace(/ /g, '').replace('*', '')); @@ -43,7 +65,75 @@ export class AzCliTool extends ToolBase { return undefined; } } - protected get versionCommand(): string { - return 'az --version'; + protected get versionCommand(): Command { + return { + command: 'az --version' + }; } } + +const win32InstallationCommands = [ + { + comment: localize('resourceDeployment.AziCli.DeletingPreviousAzureCli.msi', "deleting previously downloaded azurecli.msi if one exists ..."), + command: `IF EXIST .\\AzureCLI.msi DEL /F .\\AzureCLI.msi` + }, + { + sudo: true, + comment: localize('resourceDeployment.AziCli.DownloadingAndInstallingAzureCli', "downloading azurecli.msi and installing azure-cli ..."), + command: `powershell -Command "& {(New-Object System.Net.WebClient).DownloadFile('https://aka.ms/installazurecliwindows', 'AzureCLI.msi'); Start-Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /passive /quiet /lvx ADS_AzureCliInstall.log'}"` + }, + { + comment: localize('resourceDeployment.AziCli.DisplayingInstallationLog', "displaying the installation log ..."), + command: `type AzureCliInstall.log | findstr /i /v /c:"cached product context" | findstr /i /v /c:"has no eligible binary patches" `, + ignoreError: true + } +]; +const macOsInstallationCommands = [ + { + comment: localize('resourceDeployment.AziCli.UpdatingBrewRepository', "updating your brew repository for azure-cli installation ..."), + command: 'brew update' + }, + { + comment: localize('resourceDeployment.AziCli.InstallingAzureCli', "installing azure-cli ..."), + command: 'brew install azure-cli' + } +]; +const linuxInstallationCommands = [ + { + sudo: true, + comment: localize('resourceDeployment.AziCli.AptGetUpdate', "updating repository information before installing azure-cli ..."), + command: 'apt-get update' + }, + { + sudo: true, + comment: localize('resourceDeployment.AziCli.AptGetPackages', "getting packages needed for azure-cli installation ..."), + command: 'apt-get install ca-certificates curl apt-transport-https lsb-release gnupg -y' + }, + { + sudo: true, + comment: localize('resourceDeployment.AziCli.DownloadAndInstallingSigningKey', "downloading and installing the signing key for azure-cli ..."), + command: 'curl -sL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor | tee /etc/apt/trusted.gpg.d/microsoft.asc.gpg > /dev/null' + }, + { + sudo: true, + comment: localize('resourceDeployment.AziCli.AddingAzureCliRepositoryInformation', "adding the azure-cli repository information ..."), + command: 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ `lsb_release -cs` main" | tee /etc/apt/sources.list.d/azure-cli.list' + }, + { + sudo: true, + comment: localize('resourceDeployment.AziCli.AptGetUpdateAgain', "updating repository information again for azure-cli ..."), + command: 'apt-get update' + }, + { + sudo: true, + comment: localize('resourceDeployment.AziCli.InstallingAzureCli', "installing azure-cli ..."), + command: 'apt-get install azure-cli' + } +]; +const defaultInstallationCommands = [ + { + sudo: true, + comment: localize('resourceDeployment.AziCli.ScriptedInstall', "download and invoking script to install azure-cli ..."), + command: 'curl -sL https://aka.ms/InstallAzureCLIDeb | bash' + } +]; diff --git a/extensions/resource-deployment/src/services/tools/azdataTool.ts b/extensions/resource-deployment/src/services/tools/azdataTool.ts index 46ad98ef9e..1a214994e2 100644 --- a/extensions/resource-deployment/src/services/tools/azdataTool.ts +++ b/extensions/resource-deployment/src/services/tools/azdataTool.ts @@ -2,15 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import { ToolType } from '../../interfaces'; -import * as nls from 'vscode-nls'; -import { SemVer } from 'semver'; import { EOL } from 'os'; +import * as path from 'path'; +import { SemVer } from 'semver'; +import * as nls from 'vscode-nls'; +import { Command, OsType, ToolType } from '../../interfaces'; import { IPlatformService } from '../platformService'; import { ToolBase } from './toolBase'; const localize = nls.loadMessageBundle(); +const installationRoot = '~/.local/bin'; export class AzdataTool extends ToolBase { constructor(platformService: IPlatformService) { @@ -37,8 +38,10 @@ export class AzdataTool extends ToolBase { return 'https://docs.microsoft.com/sql/big-data-cluster/deploy-install-azdata'; } - protected get versionCommand(): string { - return 'azdata -v'; + protected get versionCommand(): Command { + return { + command: 'azdata -v' + }; } protected getVersionFromOutput(output: string): SemVer | undefined { @@ -48,4 +51,79 @@ export class AzdataTool extends ToolBase { } return version; } + + get autoInstallSupported(): boolean { + return true; + } + + protected async getInstallationPath(): Promise { + switch (this.osType) { + case OsType.linux: + return installationRoot; + default: + const azdataCliInstallLocation = await this.getPip3InstallLocation('azdata-cli'); + return azdataCliInstallLocation && path.join(azdataCliInstallLocation, '..', 'Scripts'); + } + } + + readonly allInstallationCommands: Map = new Map([ + [OsType.linux, linuxInstallationCommands], + [OsType.win32, defaultInstallationCommands], + [OsType.darwin, defaultInstallationCommands], + [OsType.others, defaultInstallationCommands] + ]); + + protected get uninstallCommand(): string | undefined { + if (this.osType !== OsType.linux) { + return defaultUninstallCommand; + } else { + return super.uninstallCommand; + } + } } + +const linuxInstallationCommands = [ + { + sudo: true, + comment: localize('resourceDeployment.Azdata.AptGetUpdate', "updating repository information ..."), + command: 'apt-get update' + }, + { + sudo: true, + comment: localize('resourceDeployment.Azdata.AptGetPackages', "getting packages needed for azdata installation ..."), + command: 'apt-get install gnupg ca-certificates curl apt-transport-https lsb-release -y' + }, + { + sudo: true, + comment: localize('resourceDeployment.Azdata.DownloadAndInstallingSigningKey', "downloading and installing the signing key for azdata ..."), + command: 'wget -qO- https://packages.microsoft.com/keys/microsoft.asc | apt-key add -' + }, + { + sudo: true, + comment: localize('resourceDeployment.Azdata.AddingAzureCliRepositoryInformation', "adding the azdata repository information ..."), + command: 'add-apt-repository "$(wget -qO- https://packages.microsoft.com/config/ubuntu/16.04/mssql-server-preview.list)"' + }, + { + sudo: true, + comment: localize('resourceDeployment.Azdata.AptGetUpdate', "updating repository information ..."), + command: 'apt-get update' + }, + { + sudo: true, + comment: localize('resourceDeployment.Azdata.InstallingAzdata', "installing azdata ..."), + command: 'apt-get install -y azdata-cli' + } +]; + +const defaultInstallationCommands = [ + { + comment: localize('resourceDeployment.Azdata.InstallUpdatePythonRequestsPackage', "installing/updating to latest version of requests python package azdata ..."), + command: `pip3 install -U requests` + }, + { + comment: localize('resourceDeployment.Azdata.InstallingAzdata', "installing azdata ..."), + command: `pip3 install -r https://aka.ms/azdata --quiet --user` + } +]; + +const defaultUninstallCommand = `pip3 uninstall -r https://aka.ms/azdata -y `; diff --git a/extensions/resource-deployment/src/services/tools/dockerTool.ts b/extensions/resource-deployment/src/services/tools/dockerTool.ts index 248e19b3f3..a2143afd32 100644 --- a/extensions/resource-deployment/src/services/tools/dockerTool.ts +++ b/extensions/resource-deployment/src/services/tools/dockerTool.ts @@ -2,10 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import { ToolType } from '../../interfaces'; -import * as nls from 'vscode-nls'; import { SemVer } from 'semver'; +import * as nls from 'vscode-nls'; +import { Command, ToolType, OsType } from '../../interfaces'; import { IPlatformService } from '../platformService'; import { ToolBase } from './toolBase'; @@ -21,7 +20,7 @@ export class DockerTool extends ToolBase { } get description(): string { - return localize('resourceDeployment.DockerDescription', 'Provides the ability to package and run an application in isolated containers'); + return localize('resourceDeployment.DockerDescription', "Provides the ability to package and run an application in isolated containers"); } get type(): ToolType { @@ -29,7 +28,7 @@ export class DockerTool extends ToolBase { } get displayName(): string { - return localize('resourceDeployment.DockerDisplayName', 'docker'); + return localize('resourceDeployment.DockerDisplayName', "docker"); } get homePage(): string { @@ -43,7 +42,15 @@ export class DockerTool extends ToolBase { } return version; } - protected get versionCommand(): string { - return 'docker version --format "{{json .}}"'; + protected get versionCommand(): Command { + return { command: 'docker version --format "{{json .}}"' }; + } + + get autoInstallSupported(): boolean { + return false; + } + + get allInstallationCommands(): Map { + throw Error('Installation of DockerTool is not supported'); } } diff --git a/extensions/resource-deployment/src/services/tools/kubeCtlTool.ts b/extensions/resource-deployment/src/services/tools/kubeCtlTool.ts index 596821b54b..4c38af320b 100644 --- a/extensions/resource-deployment/src/services/tools/kubeCtlTool.ts +++ b/extensions/resource-deployment/src/services/tools/kubeCtlTool.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ToolType } from '../../interfaces'; +import { Command, ToolType, OsType } from '../../interfaces'; import * as nls from 'vscode-nls'; import { SemVer } from 'semver'; import { IPlatformService } from '../platformService'; @@ -21,7 +21,7 @@ export class KubeCtlTool extends ToolBase { } get description(): string { - return localize('resourceDeployment.KubeCtlDescription', 'A command-line tool allows you to run commands against Kubernetes clusters'); + return localize('resourceDeployment.KubeCtlDescription', "A command-line tool allows you to run commands against Kubernetes clusters"); } get type(): ToolType { @@ -29,7 +29,7 @@ export class KubeCtlTool extends ToolBase { } get displayName(): string { - return localize('resourceDeployment.KubeCtlDisplayName', 'kubectl'); + return localize('resourceDeployment.KubeCtlDisplayName', "kubectl"); } get homePage(): string { @@ -45,7 +45,101 @@ export class KubeCtlTool extends ToolBase { return version; } - protected get versionCommand(): string { - return 'kubectl version -o json --client'; + protected get versionCommand(): Command { + return { command: 'kubectl version -o json --client' }; } + + get autoInstallSupported(): boolean { + return true; + } + + readonly allInstallationCommands: Map = new Map([ + [OsType.linux, linuxInstallationCommands], + [OsType.win32, win32InstallationCommands], + [OsType.darwin, macOsInstallationCommands], + [OsType.others, defaultInstallationCommands] + ]); } + +const macOsInstallationCommands = [ + { + comment: localize('resourceDeployment.Kubectl.UpdatingBrewRepository', "updating your brew repository for kubectl installation ..."), + command: 'brew update' + }, + { + comment: localize('resourceDeployment.Kubectl.InstallingKubeCtl', "installing kubectl ..."), + command: 'brew install kubectl' + } +]; +const linuxInstallationCommands = [ + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.AptGetUpdate', "updating repository information ..."), + command: 'apt-get update' + }, + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.AptGetPackages', "getting packages needed for kubectl installation ..."), + command: 'apt-get install -y apt-transport-https' + }, + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.DownloadAndInstallingSigningKey', "downloading and installing the signing key for kubectl ..."), + command: 'curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -' + }, + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.AddingKubectlRepositoryInformation', "adding the kubectl repository information ..."), + command: 'echo "deb https://apt.kubernetes.io/ kubernetes-xenial main" | tee -a /etc/apt/sources.list.d/kubernetes.list' + }, + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.AptGetUpdate', "updating repository information ..."), + command: 'apt-get update' + }, + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.InstallingKubectl', "installing kubectl ..."), + command: 'apt-get install -y kubectl' + } +]; +// TODO: Remove dependency on curl on Win32 and use powershell Invoke-WebRequest instead +const win32InstallationCommands = [ + { + comment: localize('resourceDeployment.Kubectl.DeletePreviousDownloadedKubectl.exe', "deleting previously downloaded kubectl.exe if one exists ..."), + command: `IF EXIST .\kubectl.exe DEL /F .\kubectl.exe`, + }, + { + comment: localize('resourceDeployment.Kubectl.DownloadingAndInstallingKubectl', "downloading and installing the latest kubectl.exe ..."), + command: `for /f %i in ('curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt') do curl -LO https://storage.googleapis.com/kubernetes-release/release/%i/bin/windows/amd64/kubectl.exe` + } +]; +const defaultInstallationCommands = [ + { + comment: localize('resourceDeployment.Kubectl.DeletePreviousDownloadedKubectl', "deleting previously downloaded kubectl if one exists ..."), + command: `[ -e ./kubectl ] && rm -f ./kubectl`, + }, + { + comment: localize('resourceDeployment.Kubectl.DownloadingKubectl', "downloading the latest kubectl release ..."), + command: 'curl -LO https://storage.googleapis.com/kubernetes-release/release/`curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt`/bin/linux/amd64/kubectl' + }, + { + comment: localize('resourceDeployment.Kubectl.MakingExecutable', "making kubectl executable ..."), + command: 'chmod +x ./kubectl', + }, + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.CleaningUpOldBackups', "cleaning up any previously backed up version in the install location if they exist ..."), + command: `[ -e /usr/local/bin/kubectl] && [ -e /usr/local/bin/kubectl_movedByADS ] && rm -f /usr/local/bin/kubectl_movedByADS` + }, + { + sudo: true, + comment: localize('resourceDeployment.Kubectl.BackupCurrentBinary', "backing up any existing kubectl in the install location ..."), + command: `[ -e /usr/local/bin/kubectl ] && mv /usr/local/bin/kubectl /usr/local/bin/kubectl_movedByADS` + }, + { + comment: localize('resourceDeployment.Kubectl.MoveToSystemPath', "moving kubectl into the install location in the PATH ..."), + sudo: true, + command: 'mv ./kubectl /usr/local/bin/kubectl' + } +]; diff --git a/extensions/resource-deployment/src/services/tools/toolBase.ts b/extensions/resource-deployment/src/services/tools/toolBase.ts index ff874d3d0a..d702248c24 100644 --- a/extensions/resource-deployment/src/services/tools/toolBase.ts +++ b/extensions/resource-deployment/src/services/tools/toolBase.ts @@ -2,62 +2,246 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import { ToolType, ITool } from '../../interfaces'; -import { SemVer } from 'semver'; -import { IPlatformService } from '../platformService'; -import * as nls from 'vscode-nls'; import { EOL } from 'os'; +import { delimiter } from 'path'; +import { SemVer } from 'semver'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import { Command, ITool, OsType, ToolStatus, ToolType } from '../../interfaces'; +import { getErrorMessage } from '../../utils'; +import { IPlatformService } from '../platformService'; + const localize = nls.loadMessageBundle(); +const toolStatusNotInstalled: string = localize('deploymentDialog.ToolStatus.NotInstalled', "Not Installed"); +const toolStatusInstalled: string = localize('deploymentDialog.ToolStatus.Installed', "Installed"); +const toolStatusInstalling: string = localize('deploymentDialog.ToolStatus.NotInstalling', "Installing ..."); +const toolStatusError: string = localize('deploymentDialog.ToolStatus.Error', "Error"); +const toolStatusFailed: string = localize('deploymentDialog.ToolStatus.Failed', "Failed"); + +const toolStatusLocalized: Map = new Map([ + [ToolStatus.Error, toolStatusError], + [ToolStatus.Installed, toolStatusInstalled], + [ToolStatus.Installing, toolStatusInstalling], + [ToolStatus.NotInstalled, toolStatusNotInstalled], + [ToolStatus.Failed, toolStatusFailed] +]); export abstract class ToolBase implements ITool { - constructor(private _platformService: IPlatformService) { } + constructor(private _platformService: IPlatformService) { + this._osType = this._platformService.osType(); + } abstract name: string; abstract displayName: string; abstract description: string; abstract type: ToolType; abstract homePage: string; - protected abstract getVersionFromOutput(output: string): SemVer | undefined; - protected abstract readonly versionCommand: string; + abstract autoInstallSupported: boolean; + abstract readonly allInstallationCommands: Map; - public get version(): SemVer | undefined { + protected abstract getVersionFromOutput(output: string): SemVer | undefined; + protected readonly _onDidUpdateData = new vscode.EventEmitter(); + protected readonly uninstallCommand?: string; + + + protected abstract readonly versionCommand: Command; + + protected async getInstallationPath(): Promise { + return undefined; + } + + protected get installationSearchPaths(): (string | undefined)[] { + return [this.storagePath]; + } + + protected get downloadPath(): string { + return this.storagePath; + } + + protected logToOutputChannel(data: string | Buffer, header?: string): void { + this._platformService.logToOutputChannel(data, header); // data and header are localized by caller + } + + public get onDidUpdateData(): vscode.Event { + return this._onDidUpdateData.event; + } + + protected get status(): ToolStatus { + return this._status; + } + + protected set status(value: ToolStatus) { + this._status = value; + this._onDidUpdateData.fire(this); + } + + public get displayStatus(): string { + return toolStatusLocalized.get(this._status); + } + + public get autoInstallRequired(): boolean { + return this.status !== ToolStatus.Installed && this.autoInstallSupported; + } + + public get isNotInstalled(): boolean { + return this.status === ToolStatus.NotInstalled; + } + + public get isInstalling(): boolean { + return this.status === ToolStatus.Installing; + } + + public get needsInstallation(): boolean { + return this.status !== ToolStatus.Installed; + } + + public get storagePath(): string { + const storagePath = this._platformService.storagePath(); + if (!this._storagePathEnsured) { + this._platformService.ensureDirectoryExists(storagePath); + this._storagePathEnsured = true; + } + return storagePath; + } + + public get osType(): OsType { + return this._osType; + } + + protected get version(): SemVer | undefined { return this._version; } - public get isInstalled(): boolean { - return this._isInstalled; + protected set version(value: SemVer | undefined) { + this._version = value; + this._onDidUpdateData.fire(this); + } + + public get fullVersion(): string | undefined { + return this._version && this._version.version; + } public get statusDescription(): string | undefined { return this._statusDescription; } - public loadInformation(): Promise { - if (this._isInstalled) { - return Promise.resolve(); - } - this._isInstalled = false; - this._statusDescription = undefined; - this._version = undefined; - this._versionOutput = undefined; - return this._platformService.runCommand(this.versionCommand).then((stdout) => { - this._versionOutput = stdout; - this._version = this.getVersionFromOutput(stdout); - if (this._version) { - this._isInstalled = true; - } else { - throw localize('deployCluster.InvalidToolVersionOutput', "Invalid output received."); - } - }).catch((error) => { - const errorMessage = typeof error === 'string' ? error : - typeof error.message === 'string' ? error.message : ''; - this._statusDescription = localize('deployCluster.GetToolVersionError', "Error retrieving version information.{0}Error: {1}{0}stdout: {2} ", EOL, errorMessage, this._versionOutput); - }); + protected get installationCommands(): Command[] | undefined { + return this.allInstallationCommands.get(this.osType); } - private _isInstalled: boolean = false; + protected async getPip3InstallLocation(packageName: string): Promise { + const command = `pip3 show ${packageName}`; + const pip3ShowOutput: string = await this._platformService.runCommand(command, { sudo: false, ignoreError: true }); + const installLocation = /^Location\: (.*)$/gim.exec(pip3ShowOutput); + let retValue = installLocation && installLocation[1]; + if (retValue === undefined || retValue === null) { + this.logToOutputChannel(` >${command}`); //command is localized by caller + this.logToOutputChannel(localize('toolBase.getPip3InstallationLocation.LocationNotFound', " Could not find 'Location' in the output:")); + this.logToOutputChannel(pip3ShowOutput, localize('toolBase.getPip3InstallationLocation.Output', " output:")); + return ''; + } else { + return retValue; + } + } + + public get outputChannelName(): string { + return this._platformService.outputChannelName(); + } + + public showOutputChannel(preserveFocus?: boolean | undefined): void { + this._platformService.showOutputChannel(preserveFocus); + } + + public async install(): Promise { + try { + this.status = ToolStatus.Installing; + await this.installCore(); + await this.addInstallationSearchPathsToSystemPath(); + this.status = await this.updateVersionAndGetStatus(); + } catch (error) { + const errorMessage = getErrorMessage(error); + this._statusDescription = localize('toolBase.InstallError', "Error installing tool '{0}' [ {1} ].{2}Error: {3}{2}See output channel '{4}' for more details", this.displayName, this.homePage, EOL, errorMessage, this.outputChannelName); + this.status = ToolStatus.Error; + throw error; + } + // Since we just completed installation, the status should be ToolStatus.Installed + // but if it is ToolStatus.NotInstalled then it means that installation failed with 0 exit code. + if (this.status === ToolStatus.NotInstalled) { + this._statusDescription = localize('toolBase.InstallFailed', "Installation commands completed but version of tool '{0}' could not be detected so our installation attempt has failed. Detection Error: {1}{2}Cleaning up previous installations would help.", this.displayName, this._statusDescription, EOL); + if (this.uninstallCommand) { + this._statusDescription += localize('toolBase.ManualUninstallCommand', " A possibly way to uninstall is using this command:{0} >{1}", EOL, this.uninstallCommand); + } + this._statusDescription += localize('toolBase.SeeOutputChannel', "{0}See output channel '{1}' for more details", EOL, this.outputChannelName); + this.status = ToolStatus.Failed; + throw new Error(this._statusDescription); + } + } + + protected async installCore() { + const installationCommands: Command[] | undefined = this.installationCommands; + if (!installationCommands || installationCommands.length === 0) { + throw new Error(localize('toolBase.installCore.CannotInstallTool', "Cannot install tool:${0}::${1} as installation commands are unknown", this.displayName, this.description)); + } + for (let i: number = 0; i < installationCommands.length; i++) { + await this._platformService.runCommand(installationCommands[i].command, + { + workingDirectory: installationCommands[i].workingDirectory || this.downloadPath, + additionalEnvironmentVariables: installationCommands[i].additionalEnvironmentVariables, + sudo: installationCommands[i].sudo, + commandTitle: installationCommands[i].comment, + ignoreError: installationCommands[i].ignoreError + }, + ); + } + } + + protected async addInstallationSearchPathsToSystemPath(): Promise { + const installationPath = await this.getInstallationPath(); + const searchPaths = [installationPath, ...this.installationSearchPaths].filter(path => !!path); + this.logToOutputChannel(localize('toolBase.addInstallationSearchPathsToSystemPath.SearchPaths', "Search Paths for tool '{0}': {1}", this.displayName, JSON.stringify(searchPaths, undefined, '\t'))); //this.displayName is localized and searchPaths are OS filesystem paths. + searchPaths.forEach(installationSearchPath => { + if (process.env.PATH) { + if (!`${delimiter}${process.env.PATH}${delimiter}`.includes(`${delimiter}${installationSearchPath}${delimiter}`)) { + process.env.PATH += `${delimiter}${installationSearchPath}`; + console.log(`Appending to Path -> ${delimiter}${installationSearchPath}`); + } + } else { + process.env.PATH = installationSearchPath; + console.log(`Appending to Path -> '${delimiter}${installationSearchPath}':${delimiter}${installationSearchPath}`); + } + }); + } + public async loadInformation(): Promise { + if (this.status === ToolStatus.NotInstalled) { + await this.addInstallationSearchPathsToSystemPath(); + this.status = await this.updateVersionAndGetStatus(); + } + } + private async updateVersionAndGetStatus(): Promise { + const commandOutput = await this._platformService.runCommand( + this.versionCommand.command, + { + workingDirectory: this.versionCommand.workingDirectory, + additionalEnvironmentVariables: this.versionCommand.additionalEnvironmentVariables, + sudo: false, + ignoreError: true + }, + ); + this.version = this.getVersionFromOutput(commandOutput); + if (this.version) { + return ToolStatus.Installed; + } + else { + this._statusDescription = localize('deployCluster.GetToolVersionError', "Error retrieving version information.{0}Invalid output received, get version command output: '{1}' ", EOL, commandOutput); + return ToolStatus.NotInstalled; + } + } + + private _storagePathEnsured: boolean = false; + private _status: ToolStatus = ToolStatus.NotInstalled; + private _osType: OsType; private _version?: SemVer; private _statusDescription?: string; - private _versionOutput?: string; + } diff --git a/extensions/resource-deployment/src/test/toolsService.test.ts b/extensions/resource-deployment/src/test/toolsService.test.ts index 5715bc43d6..e8a5b5f142 100644 --- a/extensions/resource-deployment/src/test/toolsService.test.ts +++ b/extensions/resource-deployment/src/test/toolsService.test.ts @@ -18,7 +18,7 @@ suite('Tools Service Tests', function (): void { const toolsService = new ToolsService(mockPlatformService.object); const tools: { name: string; type: ToolType }[] = [ - { name: 'azcli', type: ToolType.AzCli }, + { name: 'azure-cli', type: ToolType.AzCli }, { name: 'docker', type: ToolType.Docker }, { name: 'kubectl', type: ToolType.KubeCtl }, { name: 'azdata', type: ToolType.Azdata }]; diff --git a/extensions/resource-deployment/src/typings/ref.d.ts b/extensions/resource-deployment/src/typings/ref.d.ts index 4d46be908b..31c2c56a19 100644 --- a/extensions/resource-deployment/src/typings/ref.d.ts +++ b/extensions/resource-deployment/src/typings/ref.d.ts @@ -6,4 +6,5 @@ /// /// /// -/// \ No newline at end of file +/// +/// diff --git a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts index aa9c81f5a3..795ff2a6d8 100644 --- a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts +++ b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts @@ -2,14 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - import * as azdata from 'azdata'; +import { EOL } from 'os'; import * as nls from 'vscode-nls'; -import { DialogBase } from './dialogBase'; -import { ResourceType, AgreementInfo, DeploymentProvider } from '../interfaces'; +import { AgreementInfo, DeploymentProvider, ITool, ResourceType } from '../interfaces'; import { IResourceTypeService } from '../services/resourceTypeService'; import { IToolsService } from '../services/toolsService'; -import { EOL } from 'os'; +import { getErrorMessage } from '../utils'; +import { DialogBase } from './dialogBase'; import { createFlexContainer } from './modelViewUtils'; const localize = nls.loadMessageBundle(); @@ -27,6 +27,9 @@ export class ResourceTypePickerDialog extends DialogBase { private _toolsLoadingComponent!: azdata.LoadingComponent; private _agreementContainer!: azdata.DivContainer; private _agreementCheckboxChecked: boolean = false; + private _installToolButton: azdata.window.Button; + private _tools: ITool[] = []; + private _cardsContainer!: azdata.FlexContainer; constructor( private toolsService: IToolsService, @@ -34,7 +37,14 @@ export class ResourceTypePickerDialog extends DialogBase { resourceType: ResourceType) { super(localize('resourceTypePickerDialog.title', "Select the deployment options"), 'ResourceTypePickerDialog', true); this._selectedResourceType = resourceType; - this._dialogObject.okButton.label = localize('deploymentDialog.OKButtonText', 'Select'); + this._dialogObject.okButton.onClick(() => this.onComplete()); + this._installToolButton = azdata.window.createButton(localize('deploymentDialog.InstallToolsButton', "Install tools")); + this._toDispose.push(this._installToolButton.onClick(() => { + this.installTools(); + })); + this._dialogObject.customButtons = [this._installToolButton]; + this._installToolButton.hidden = true; + this._dialogObject.okButton.label = localize('deploymentDialog.OKButtonText', "Select"); } initialize() { @@ -55,24 +65,24 @@ export class ResourceTypePickerDialog extends DialogBase { this.resourceTypeService.getResourceTypes().sort((a: ResourceType, b: ResourceType) => { return (a.displayIndex || Number.MAX_VALUE) - (b.displayIndex || Number.MAX_VALUE); }).forEach(resourceType => this.addCard(resourceType)); - const cardsContainer = view.modelBuilder.flexContainer().withItems(this._resourceTypeCards, { flex: '0 0 auto', CSSStyles: { 'margin-bottom': '10px' } }).withLayout({ flexFlow: 'row' }).component(); + this._cardsContainer = view.modelBuilder.flexContainer().withItems(this._resourceTypeCards, { flex: '0 0 auto', CSSStyles: { 'margin-bottom': '10px' } }).withLayout({ flexFlow: 'row' }).component(); this._resourceDescriptionLabel = view.modelBuilder.text().withProperties({ value: this._selectedResourceType ? this._selectedResourceType.description : undefined }).component(); this._optionsContainer = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); this._agreementContainer = view.modelBuilder.divContainer().component(); const toolColumn: azdata.TableColumn = { - value: localize('deploymentDialog.toolNameColumnHeader', 'Tool'), + value: localize('deploymentDialog.toolNameColumnHeader', "Tool"), width: 150 }; const descriptionColumn: azdata.TableColumn = { - value: localize('deploymentDialog.toolDescriptionColumnHeader', 'Description'), + value: localize('deploymentDialog.toolDescriptionColumnHeader', "Description"), width: 650 }; const installStatusColumn: azdata.TableColumn = { - value: localize('deploymentDialog.toolStatusColumnHeader', 'Installed'), + value: localize('deploymentDialog.toolStatusColumnHeader', "Status"), width: 100 }; const versionColumn: azdata.TableColumn = { - value: localize('deploymentDialog.toolVersionColumnHeader', 'Version'), + value: localize('deploymentDialog.toolVersionColumnHeader', "Version"), width: 100 }; @@ -88,7 +98,7 @@ export class ResourceTypePickerDialog extends DialogBase { const formBuilder = view.modelBuilder.formContainer().withFormItems( [ { - component: cardsContainer, + component: this._cardsContainer, title: '' }, { component: this._resourceDescriptionLabel, @@ -99,10 +109,10 @@ export class ResourceTypePickerDialog extends DialogBase { }, { component: this._optionsContainer, - title: localize('deploymentDialog.OptionsTitle', 'Options') + title: localize('deploymentDialog.OptionsTitle', "Options") }, { component: this._toolsLoadingComponent, - title: localize('deploymentDialog.RequiredToolsTitle', 'Required tools') + title: localize('deploymentDialog.RequiredToolsTitle', "Required tools") } ], { @@ -178,15 +188,15 @@ export class ResourceTypePickerDialog extends DialogBase { width: '300px' }).component(); - this._toDispose.push(optionSelectBox.onValueChanged(() => { this.updateTools(); })); + this._toDispose.push(optionSelectBox.onValueChanged(() => { this.updateToolsDisplayTable(); })); this._optionDropDownMap.set(option.name, optionSelectBox); const row = this._view.modelBuilder.flexContainer().withItems([optionLabel, optionSelectBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '20px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); this._optionsContainer.addItem(row); }); - this.updateTools(); + this.updateToolsDisplayTable(); } - private updateTools(): void { + private updateToolsDisplayTable(): void { this.toolRefreshTimestamp = new Date().getTime(); const currentRefreshTimestamp = this.toolRefreshTimestamp; const toolRequirements = this.getCurrentProvider().requiredTools; @@ -195,40 +205,58 @@ export class ResourceTypePickerDialog extends DialogBase { this._dialogObject.message = { text: '' }; + this._installToolButton.hidden = true; if (toolRequirements.length === 0) { this._dialogObject.okButton.enabled = true; this._toolsTable.data = [[localize('deploymentDialog.NoRequiredTool', "No tools required"), '']]; + this._tools = []; } else { - const tools = toolRequirements.map(toolReq => { + this._tools = toolRequirements.map(toolReq => { return this.toolsService.getToolByName(toolReq.name)!; }); this._toolsLoadingComponent.loading = true; this._dialogObject.okButton.enabled = false; - Promise.all(tools.map(tool => tool.loadInformation())).then(() => { + Promise.all(this._tools.map(tool => tool.loadInformation())).then(async () => { // If the local timestamp does not match the class level timestamp, it means user has changed options, ignore the results if (this.toolRefreshTimestamp !== currentRefreshTimestamp) { return; } + let autoInstallRequired = false; const messages: string[] = []; - this._toolsTable.data = toolRequirements.map(toolRef => { - const tool = this.toolsService.getToolByName(toolRef.name)!; - if (!tool.isInstalled) { - messages.push(localize('deploymentDialog.ToolInformation', "{0}: {1}", tool.displayName, tool.homePage)); + this._toolsTable.data = toolRequirements.map(toolReq => { + const tool = this.toolsService.getToolByName(toolReq.name)!; + // subscribe to onUpdateData event of the tool. + this._toDispose.push(tool.onDidUpdateData((t: ITool) => { + this.updateToolsDisplayTableData(t); + })); + if (tool.isNotInstalled && !tool.autoInstallSupported) { + messages.push(localize('deploymentDialog.ToolInformation', "'{0}' [ {1} ]", tool.displayName, tool.homePage)); if (tool.statusDescription !== undefined) { - console.warn(localize('deploymentDialog.DetailToolStatusDescription', "Additional status information for tool: {0}. {1}", tool.name, tool.statusDescription)); + console.warn(localize('deploymentDialog.DetailToolStatusDescription', "Additional status information for tool: '{0}' [ {1} ]. {2}", tool.name, tool.homePage, tool.statusDescription)); } } - return [tool.displayName, tool.description, tool.isInstalled ? localize('deploymentDialog.YesText', "Yes") : localize('deploymentDialog.NoText', "No"), tool.version ? tool.version.version : '']; + + autoInstallRequired = tool.autoInstallRequired; + return [tool.displayName, tool.description, tool.displayStatus, tool.fullVersion || '']; }); - this._dialogObject.okButton.enabled = messages.length === 0; + + this._installToolButton.hidden = !autoInstallRequired; + this._dialogObject.okButton.enabled = messages.length === 0 && !autoInstallRequired; if (messages.length !== 0) { messages.push(localize('deploymentDialog.VersionInformationDebugHint', "You will need to restart Azure Data Studio if the tools are installed after Azure Data Studio is launched to pick up the updated PATH environment variable. You may find additional details in the debug console.")); this._dialogObject.message = { level: azdata.window.MessageLevel.Error, - text: localize('deploymentDialog.ToolCheckFailed', "Some required tools are not installed or do not meet the minimum version requirement."), + text: localize('deploymentDialog.ToolCheckFailed', "Some required tools are not installed."), description: messages.join(EOL) }; + } else if (autoInstallRequired) { + // we don't have scenarios that have mixed type of tools + // either we don't support auto install: docker, or we support auto install for all required tools + this._dialogObject.message = { + level: azdata.window.MessageLevel.Information, + text: localize('deploymentDialog.InstallToolsHint', "Some required tools are not installed, you can click the \"{0}\" button to install them.", this._installToolButton.label) + }; } this._toolsLoadingComponent.loading = false; }); @@ -263,4 +291,56 @@ export class ResourceTypePickerDialog extends DialogBase { protected onComplete(): void { this.resourceTypeService.startDeployment(this.getCurrentProvider()); } + + public updateToolsDisplayTableData(tool: ITool) { + this._toolsTable.data = this._toolsTable.data.map(rowData => { + if (rowData[0] === tool.displayName) { + return [tool.displayName, tool.description, tool.displayStatus, tool.fullVersion || '']; + } else { + return rowData; + } + }); + this.enableUiControlsWhenNotInstalling(!tool.isInstalling); // if installing the disableContainers else enable them + } + + private enableUiControlsWhenNotInstalling(enabled: boolean): void { + this._cardsContainer.enabled = enabled; + this._agreementContainer.enabled = enabled; + this._optionsContainer.enabled = enabled; + this._dialogObject.cancelButton.enabled = enabled; + // select and install tools button are controlled separately + } + + private async installTools(): Promise { + this._installToolButton.enabled = false; + let i: number = 0; + try { + for (; i < this._tools.length; i++) { + if (this._tools[i].needsInstallation) { + // Update the informational message + this._dialogObject.message = { + level: azdata.window.MessageLevel.Information, + text: localize('deploymentDialog.InstallingTool', "Required tool '{0}' [ {1} ] is being installed now.", this._tools[i].displayName, this._tools[i].homePage) + }; + await this._tools[i].install(); + } + } + // Update the informational message + this._dialogObject.message = { + level: azdata.window.MessageLevel.Information, + text: localize('deploymentDialog.InstalledTools', "All required tools are installed now.") + }; + this._dialogObject.okButton.enabled = true; + } catch (error) { + const errorMessage = this._tools[i].statusDescription || getErrorMessage(error); + if (errorMessage) { + // Let the tooltip status show the errorMessage just shown so that last status is visible even after showError dialogue has been dismissed. + this._dialogObject.message = { + level: azdata.window.MessageLevel.Error, + text: errorMessage + }; + } + this._tools[i].showOutputChannel(/*preserverFocus*/false); + } + } } diff --git a/extensions/resource-deployment/src/ui/wizardBase.ts b/extensions/resource-deployment/src/ui/wizardBase.ts index 98b8212ef0..a963e5d34b 100644 --- a/extensions/resource-deployment/src/ui/wizardBase.ts +++ b/extensions/resource-deployment/src/ui/wizardBase.ts @@ -68,19 +68,19 @@ export abstract class WizardBase { } private dispose() { - let errorOccured = false; + let errorOccurred = false; this.toDispose.forEach((disposable: vscode.Disposable) => { try { disposable.dispose(); } catch (error) { - errorOccured = true; + errorOccurred = true; console.error(error); } }); - if (errorOccured) { - vscode.window.showErrorMessage(localize('resourceDeployment.DisposableError', "Error occured while closing the wizard: {0}, open 'Debugger Console' for more information."), this.title); + if (errorOccurred) { + vscode.window.showErrorMessage(localize('resourceDeployment.DisposableError', "Error occurred while closing the wizard: {0}, open 'Debugger Console' for more information."), this.title); } } diff --git a/extensions/resource-deployment/src/utils.ts b/extensions/resource-deployment/src/utils.ts index fcb2797aae..4b83b7e9f7 100644 --- a/extensions/resource-deployment/src/utils.ts +++ b/extensions/resource-deployment/src/utils.ts @@ -3,8 +3,10 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export function getErrorMessage(error: string | Error): string { - return typeof error === 'string' ? error : error.message; +export function getErrorMessage(error: any): string { + return (error instanceof Error) + ? (typeof error.message === 'string' ? error.message : '') + : typeof error === 'string' ? error : `${JSON.stringify(error, undefined, '\t')}`; } export function getDateTimeString(): string { diff --git a/extensions/resource-deployment/yarn.lock b/extensions/resource-deployment/yarn.lock index c820c57fb2..4f126cf79a 100644 --- a/extensions/resource-deployment/yarn.lock +++ b/extensions/resource-deployment/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@babel/runtime@^7.1.5": + version "7.6.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.6.3.tgz#935122c74c73d2240cafd32ddb5fc2a6cd35cf1f" + integrity sha512-kq6anf9JGjW8Nt5rYfEuGRaEAaH1mkv3Bbu6rYvLOpPh/RusSJXuKPEAoZ7L7gybZkchE8+NV5g9vKF4AGAtsA== + dependencies: + regenerator-runtime "^0.13.2" + "@types/yamljs@0.2.30": version "0.2.30" resolved "https://registry.yarnpkg.com/@types/yamljs/-/yamljs-0.2.30.tgz#d034e1d329e46e8d0f737c9a8db97f68f81b5382" @@ -506,6 +513,13 @@ postinstall-build@^5.0.1: resolved "https://registry.yarnpkg.com/postinstall-build/-/postinstall-build-5.0.3.tgz#238692f712a481d8f5bc8960e94786036241efc7" integrity sha512-vPvPe8TKgp4FLgY3+DfxCE5PIfoXBK2lyLfNCxsRbDsV6vS4oU5RG/IWxrblMn6heagbnMED3MemUQllQ2bQUg== +promisify-child-process@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/promisify-child-process/-/promisify-child-process-3.1.1.tgz#3a029c1d97bdb8bbcc8862c765b91f1cee0f2691" + integrity sha512-683UHZEP4Bm75BvBujEe87AdE9lxnoWpcU5pEw4FG9HCSwwZC9pF7HUj3QmlDAvhyvulkWHLZs1lVRBNTvkbXQ== + dependencies: + "@babel/runtime" "^7.1.5" + psl@^1.1.24: version "1.1.31" resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" @@ -531,6 +545,11 @@ querystringify@^2.1.1: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== +regenerator-runtime@^0.13.2: + version "0.13.3" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" + integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== + request@^2.88.0: version "2.88.0" resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" @@ -617,6 +636,11 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +sudo-prompt@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/sudo-prompt/-/sudo-prompt-9.0.0.tgz#eebedeee9fcd6f661324e6bb46335e3288e8dc8a" + integrity sha512-kUn5fiOk0nhY2oKD9onIkcNCE4Zt85WTsvOfSmqCplmlEvXCcPOmp1npH5YWuf8Bmyy9wLWkIxx+D+8cThBORQ== + supports-color@4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e" diff --git a/src/typings/sudo-prompt.d.ts b/src/typings/sudo-prompt.d.ts index 9149efa19a..8bf5e71785 100644 --- a/src/typings/sudo-prompt.d.ts +++ b/src/typings/sudo-prompt.d.ts @@ -4,6 +4,10 @@ *--------------------------------------------------------------------------------------------*/ declare module 'sudo-prompt' { - - export function exec(cmd: string, options: { name?: string, icns?: string }, callback: (error: string, stdout: string, stderr: string) => void): void; -} \ No newline at end of file + type SudoOptions = { + name?: string; + icns?: string; + env?: NodeJS.ProcessEnv; + }; + export function exec(cmd: string, options: SudoOptions, callback: (error: string, stdout: string, stderr: string) => void): void; +}