diff --git a/extensions/azcli/package.json b/extensions/azcli/package.json index 0033d85a4f..53703b8451 100644 --- a/extensions/azcli/package.json +++ b/extensions/azcli/package.json @@ -32,6 +32,45 @@ "type": "boolean", "default": false, "description": "%azcli.arc.config.debug%" + }, + "azcli.arcdataInstall": { + "type": "string", + "default": "prompt", + "enum": [ + "dontPrompt", + "prompt" + ], + "enumDescriptions": [ + "%azcli.arc.install.dontPrompt.description%", + "%azcli.arc.install.prompt.description%" + ], + "description": "%azcli.arc.azArcdataInstallKey.description%" + }, + "azcli.arcdataUpdate": { + "type": "string", + "default": "prompt", + "enum": [ + "dontPrompt", + "prompt" + ], + "enumDescriptions": [ + "%azcli.arc.update.dontPrompt.description%", + "%azcli.arc.update.prompt.description%" + ], + "description": "%azcli.arc.azArcdataUpdateKey.description%" + }, + "azcli.azInstall": { + "type": "string", + "default": "prompt", + "enum": [ + "dontPrompt", + "prompt" + ], + "enumDescriptions": [ + "%azcli.install.dontPrompt.description%", + "%azcli.install.prompt.description%" + ], + "description": "%azcli.azCliInstallKey.description%" } } } diff --git a/extensions/azcli/package.nls.json b/extensions/azcli/package.nls.json index 6b6aeffaed..a4cd4464a2 100644 --- a/extensions/azcli/package.nls.json +++ b/extensions/azcli/package.nls.json @@ -5,5 +5,18 @@ "azcli.arc.config.debug": "Log debug info to the output channel for all executed az commands", "command.category": "Azure CLI", - "azcli.arc.category": "Azure CLI" + "azcli.arc.category": "Azure CLI", + + "azcli.arc.azArcdataInstallKey.description": "Choose whether you will be prompted to download the Azure CLI arcdata extension.", + "azcli.arc.install.prompt.description": "The user will be prompted to install the Azure CLI arcdata extension", + "azcli.arc.install.dontPrompt.description": "The user will not be prompted to install the Azure CLI arcdata extension", + + "azcli.arc.azArcdataUpdateKey.description": "Choose whether you will be prompted when an update of the Azure CLI arcdata extension is available.", + "azcli.arc.update.prompt.description": "The user will be prompted for update of the Azure CLI arcdata extension", + "azcli.arc.update.dontPrompt.description": "The user will not be prompted for update of the Azure CLI arcdata extension", + + "azcli.azCliInstallKey.description": "Choose whether you will be prompted to install Azure CLI.", + "azcli.install.prompt.description": "The user will be prompted to install the Azure CLI", + "azcli.install.dontPrompt.description": "The user will not be prompted to install the Azure CLI" + } diff --git a/extensions/azcli/src/api.ts b/extensions/azcli/src/api.ts index 77143b02b2..3119f35df3 100644 --- a/extensions/azcli/src/api.ts +++ b/extensions/azcli/src/api.ts @@ -26,28 +26,31 @@ export function throwIfNoAz(localAz: IAzTool | undefined): asserts localAz { } } -export function getExtensionApi(azToolService: AzToolService): azExt.IExtension { +export function getExtensionApi(azToolService: AzToolService, localAzDiscovered: Promise): azExt.IExtension { return { - az: getAzApi(azToolService) + az: getAzApi(localAzDiscovered, azToolService) }; } -export function getAzApi(azToolService: AzToolService): azExt.IAzApi { +export function getAzApi(localAzDiscovered: Promise, azToolService: AzToolService): azExt.IAzApi { return { arcdata: { dc: { endpoint: { list: async (namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.arcdata.dc.endpoint.list(namespace, additionalEnvVars); } }, config: { list: async (additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.arcdata.dc.config.list(additionalEnvVars); }, show: async (namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.arcdata.dc.config.show(namespace, additionalEnvVars); } @@ -57,14 +60,17 @@ export function getAzApi(azToolService: AzToolService): azExt.IAzApi { postgres: { arcserver: { delete: async (name: string, namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.postgres.arcserver.delete(name, namespace, additionalEnvVars); }, list: async (namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.postgres.arcserver.list(namespace, additionalEnvVars); }, show: async (name: string, namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.postgres.arcserver.show(name, namespace, additionalEnvVars); }, @@ -87,6 +93,7 @@ export function getAzApi(azToolService: AzToolService): azExt.IAzApi { }, namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.postgres.arcserver.edit(name, args, namespace, additionalEnvVars); } @@ -95,14 +102,17 @@ export function getAzApi(azToolService: AzToolService): azExt.IAzApi { sql: { miarc: { delete: async (name: string, namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.sql.miarc.delete(name, namespace, additionalEnvVars); }, list: async (namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.sql.miarc.list(namespace, additionalEnvVars); }, show: async (name: string, namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.sql.miarc.show(name, namespace, additionalEnvVars); }, @@ -118,20 +128,29 @@ export function getAzApi(azToolService: AzToolService): azExt.IAzApi { namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars ) => { + await localAzDiscovered; validateAz(azToolService.localAz); return azToolService.localAz!.sql.miarc.edit(name, args, namespace, additionalEnvVars); } } }, getPath: async () => { + await localAzDiscovered; throwIfNoAz(azToolService.localAz); return azToolService.localAz.getPath(); }, - getSemVersion: async () => { + getSemVersionAz: async () => { + await localAzDiscovered; throwIfNoAz(azToolService.localAz); - return azToolService.localAz.getSemVersion(); + return azToolService.localAz.getSemVersionAz(); + }, + getSemVersionArc: async () => { + await localAzDiscovered; + throwIfNoAz(azToolService.localAz); + return azToolService.localAz.getSemVersionArc(); }, version: async () => { + await localAzDiscovered; throwIfNoAz(azToolService.localAz); return azToolService.localAz.version(); } diff --git a/extensions/azcli/src/az.ts b/extensions/azcli/src/az.ts index 54f483d5cf..fa6c8ee209 100644 --- a/extensions/azcli/src/az.ts +++ b/extensions/azcli/src/az.ts @@ -6,12 +6,14 @@ import * as azExt from 'az-ext'; import * as fs from 'fs'; import * as os from 'os'; +import * as path from 'path'; import { SemVer } from 'semver'; import * as vscode from 'vscode'; -import { executeCommand, ExitCodeError, ProcessOutput } from './common/childProcess'; +import { executeCommand, executeSudoCommand, ExitCodeError, ProcessOutput } from './common/childProcess'; +import { HttpClient } from './common/httpClient'; import Logger from './common/logger'; -import { NoAzureCLIError, searchForCmd } from './common/utils'; -import { azConfigSection, azFound, debugConfigKey, latestAzArcExtensionVersion } from './constants'; +import { AzureCLIArcExtError, NoAzureCLIError, searchForCmd } from './common/utils'; +import { azArcdataInstallKey, azConfigSection, azFound, debugConfigKey, latestAzArcExtensionVersion, azCliInstallKey, azArcFound, azHostname, azUri } from './constants'; import * as loc from './localizedConstants'; /** @@ -41,19 +43,30 @@ export interface IAzTool extends azExt.IAzApi { */ export class AzTool implements azExt.IAzApi { - private _semVersion: SemVer; + private _semVersionAz: SemVer; + private _semVersionArc: SemVer; - constructor(private _path: string, version: string) { - this._semVersion = new SemVer(version); + constructor(private _path: string, versionAz: string, versionArc: string) { + this._semVersionAz = new SemVer(versionAz); + this._semVersionArc = new SemVer(versionArc); } /** - * The semVersion corresponding to this installation of az. version() method should have been run + * The semVersion corresponding to this installation of Azure CLI. version() method should have been run * before fetching this value to ensure that correct value is returned. This is almost always correct unless * Az has gotten reinstalled in the background after this IAzApi object was constructed. */ - public async getSemVersion(): Promise { - return this._semVersion; + public async getSemVersionAz(): Promise { + return this._semVersionAz; + } + + /** + * The semVersion corresponding to this installation of Azure CLI arcdata extension. version() method should have been run + * before fetching this value to ensure that correct value is returned. This is almost always correct unless + * arcdata has gotten reinstalled in the background after this IAzApi object was constructed. + */ + public async getSemVersionArc(): Promise { + return this._semVersionArc; } /** @@ -170,7 +183,7 @@ export class AzTool implements azExt.IAzApi { */ public async version(): Promise> { const output = await executeAzCommand(`"${this._path}"`, ['--version']); - this._semVersion = new SemVer(parseVersion(output.stdout)); + this._semVersionAz = new SemVer(parseVersion(output.stdout)); return { stdout: output.stdout, stderr: output.stderr.split(os.EOL) @@ -218,44 +231,271 @@ export class AzTool implements azExt.IAzApi { } /** - * Finds and returns the existing installation of Azure CLI, or throws an error if it can't find it - * or encountered an unexpected error. - * The promise is rejected when Azure CLI is not found. + * Checks whether az is installed - and if it is not then invokes the process of az installation. + * @param userRequested true means that this operation by was requested by a user by executing an ads command. */ -export async function findAz(): Promise { +export async function checkAndInstallAz(userRequested: boolean = false): Promise { + try { + return await findAzAndArc(); // find currently installed Az + } catch (err) { + if (err === AzureCLIArcExtError) { + // Az found but arcdata extension not found. Prompt user to install it, then check again. + if (await promptToInstallArcdata(userRequested)) { + return await findAzAndArc(); + } + } else { + // No az was found. Prompt user to install it, then check again. + if (await promptToInstallAz(userRequested)) { + return await findAzAndArc(); + } + } + } + // If user declines to install upon prompt, return an undefined object instead of an AzTool + return undefined; +} + +/** + * Finds the existing installation of az, or throws an error if it couldn't find it + * or encountered an unexpected error. If arcdata extension was not found on the az, + * throw an error. An AzTool will not be returned. + * The promise is rejected when Az is not found. + */ +export async function findAzAndArc(): Promise { Logger.log(loc.searchingForAz); try { - const az = await findSpecificAz(); - Logger.log(loc.foundExistingAz(await az.getPath(), (await az.getSemVersion()).raw)); - return az; + const azTool = await findSpecificAzAndArc(); + await vscode.commands.executeCommand('setContext', azFound, true); // save a context key that az was found so that command for installing az is no longer available in commandPalette and that for updating it is. + await vscode.commands.executeCommand('setContext', azArcFound, true); // save a context key that arcdata was found so that command for installing arcdata is no longer available in commandPalette and that for updating it is. + Logger.log(loc.foundExistingAz(await azTool.getPath(), (await azTool.getSemVersionAz()).raw, (await azTool.getSemVersionArc()).raw)); + return azTool; } catch (err) { - Logger.log(loc.noAzureCLI); + if (err === AzureCLIArcExtError) { + Logger.log(loc.couldNotFindAzArc(err)); + Logger.log(loc.noAzArc); + await vscode.commands.executeCommand('setContext', azArcFound, false); // save a context key that az was not found so that command for installing az is available in commandPalette and that for updating it is no longer available. + } else { + Logger.log(loc.couldNotFindAz(err)); + Logger.log(loc.noAz); + await vscode.commands.executeCommand('setContext', azFound, false); // save a context key that arcdata was not found so that command for installing arcdata is available in commandPalette and that for updating it is no longer available. + } throw err; } } +/** + * Find az by searching user's directories. If no az is found, this will error out and no arcdata is found. + * If az is found, check if arcdata extension exists on it and return true if so, false if not. + * Return the AzTool whether or not an arcdata extension has been found. + */ +async function findSpecificAzAndArc(): Promise { + // Check if az exists + const path = await ((process.platform === 'win32') ? searchForCmd('az.cmd') : searchForCmd('az')); + const versionOutput = await executeAzCommand(`"${path}"`, ['--version']); + + // The arcdata extension can't exist if there is no az. The function will not reach the following code + // if no az has been found. If found, check if az arcdata extension exists. + const arcVersion = parseArcExtensionVersion(versionOutput.stdout); + if (arcVersion === undefined) { + throw AzureCLIArcExtError; + } + + return new AzTool(path, parseVersion(versionOutput.stdout), arcVersion); +} + +/** + * Prompt user to install Azure CLI. + * @param userRequested - if true this operation was requested in response to a user issued command, if false it was issued at startup by system + * returns true if installation was done and false otherwise. + */ +async function promptToInstallAz(userRequested: boolean = false): Promise { + let response: string | undefined = loc.yes; + const config = getAzConfig(azCliInstallKey); + if (userRequested) { + Logger.show(); + Logger.log(loc.userRequestedInstall); + } + if (config === AzDeployOption.dontPrompt && !userRequested) { + Logger.log(loc.skipInstall(config)); + return false; + } + const responses = userRequested + ? [loc.yes, loc.no] + : [loc.yes, loc.askLater, loc.doNotAskAgain]; + if (config === AzDeployOption.prompt) { + Logger.log(loc.promptForAzInstallLog); + response = await vscode.window.showErrorMessage(loc.promptForAzInstall, ...responses); + Logger.log(loc.userResponseToInstallPrompt(response)); + } + if (response === loc.doNotAskAgain) { + await setAzConfig(azCliInstallKey, AzDeployOption.dontPrompt); + } else if (response === loc.yes) { + try { + await installAz(); + vscode.window.showInformationMessage(loc.azInstalled); + Logger.log(loc.azInstalled); + return true; + } catch (err) { + // Windows: 1602 is User cancelling installation/update - not unexpected so don't display + if (!(err instanceof ExitCodeError) || err.code !== 1602) { + vscode.window.showWarningMessage(loc.installError(err)); + Logger.log(loc.installError(err)); + } + } + } + return false; +} + +/** + * Prompt user to install Azure CLI arcdata extension. + * @param userRequested - if true this operation was requested in response to a user issued command, if false it was issued at startup by system + * returns true if installation was done and false otherwise. + */ +async function promptToInstallArcdata(userRequested: boolean = false): Promise { + let response: string | undefined = loc.yes; + const config = getAzConfig(azArcdataInstallKey); + if (userRequested) { + Logger.show(); + Logger.log(loc.userRequestedInstall); + } + if (config === AzDeployOption.dontPrompt && !userRequested) { + Logger.log(loc.skipInstall(config)); + return false; + } + const responses = userRequested + ? [loc.yes, loc.no] + : [loc.yes, loc.askLater, loc.doNotAskAgain]; + if (config === AzDeployOption.prompt) { + Logger.log(loc.promptForArcdataInstallLog); + response = await vscode.window.showErrorMessage(loc.promptForArcdataInstall, ...responses); + Logger.log(loc.userResponseToInstallPrompt(response)); + } + if (response === loc.doNotAskAgain) { + await setAzConfig(azArcdataInstallKey, AzDeployOption.dontPrompt); + } else if (response === loc.yes) { + try { + await installArcdata(); + vscode.window.showInformationMessage(loc.arcdataInstalled); + Logger.log(loc.arcdataInstalled); + return true; + } catch (err) { + // Windows: 1602 is User cancelling installation/update - not unexpected so don't display + if (!(err instanceof ExitCodeError) || err.code !== 1602) { + vscode.window.showWarningMessage(loc.installError(err)); + Logger.log(loc.installError(err)); + } + } + } + return false; +} + +/** + * runs the commands to install az, downloading the installation package if needed + */ +export async function installAz(): Promise { + Logger.show(); + Logger.log(loc.installingAz); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: loc.installingAz, + cancellable: false + }, + async (_progress, _token): Promise => { + switch (process.platform) { + case 'win32': + await downloadAndInstallAzWin32(); + break; + case 'darwin': + await installAzDarwin(); + break; + case 'linux': + await installAzLinux(); + break; + default: + throw new Error(loc.platformUnsupported(process.platform)); + } + } + ); +} + +/** + * Downloads the Windows installer and runs it + */ +async function downloadAndInstallAzWin32(): Promise { + const downLoadLink = `${azHostname}/${azUri}`; + const downloadFolder = os.tmpdir(); + const downloadLogs = path.join(downloadFolder, 'ads_az_install_logs.log'); + const downloadedFile = await HttpClient.downloadFile(downLoadLink, downloadFolder); + + try { + await executeSudoCommand(`msiexec /qn /i "${downloadedFile}" /lvx "${downloadLogs}"`); + } catch (err) { + throw new Error(`${err.message}. See logs at ${downloadLogs} for more details.`); + } +} + +/** + * Runs commands to install az on MacOS + */ +async function installAzDarwin(): Promise { + await executeCommand('brew', ['update']); + await executeCommand('brew', ['install', 'azure-cli']); +} + +/** + * Runs commands to install az on Linux + */ +async function installAzLinux(): Promise { + // Get packages needed for install process + await executeSudoCommand('apt-get update'); + await executeSudoCommand('apt-get install ca-certificates curl apt-transport-https lsb-release gnupg'); + // Download and install the signing key + await executeSudoCommand('curl -sL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/microsoft.gpg > /dev/null'); + // Add the az repository information + await executeSudoCommand('AZ_REPO=$(lsb_release -cs) echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $AZ_REPO main" | sudo tee /etc/apt/sources.list.d/azure-cli.list'); + // Update repository information and install az + await executeSudoCommand('apt-get update'); + await executeSudoCommand('apt-get install azure-cli'); +} + +/** + * Runs the command to install az arcdata extension + */ +export async function installArcdata(): Promise { + Logger.show(); + Logger.log(loc.installingArcdata); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: loc.installingArcdata, + cancellable: false + }, + async (_progress, _token): Promise => { + await executeCommand('az', ['extension', 'add', '--name', 'arcdata']); + } + ); +} + /** * Parses out the Azure CLI version from the raw az version output * @param raw The raw version output from az --version */ -function parseVersion(raw: string): string { +function parseVersion(raw: string): string | undefined { // Currently the version is a multi-line string that contains other version information such // as the Python installation, with the first line holding the version of az itself. // // The output of az --version looks like: // azure-cli 2.26.1 // ... - const start = raw.search('azure-cli'); - const end = raw.search('core'); - raw = raw.slice(start, end).replace('azure-cli', ''); - return raw.trim(); + const exp = /azure-cli\s*(\d*.\d*.\d*)/; + return exp.exec(raw)?.pop(); } /** * Parses out the arcdata extension version from the raw az version output * @param raw The raw version output from az --version */ -function parseArcExtensionVersion(raw: string): string { +function parseArcExtensionVersion(raw: string): string | undefined { // Currently the version is a multi-line string that contains other version information such // as the Python installation and any extensions. // @@ -266,15 +506,8 @@ function parseArcExtensionVersion(raw: string): string { // arcdata 1.0.0 // connectedk8s 1.1.5 // ... - const start = raw.search('arcdata'); - if (start === -1) { - // Commented the install/update prompts out until DoNotAskAgain is implemented - //throw new AzureCLIArcExtError(); - } else { - raw = raw.slice(start + 7); - raw = raw.split(os.EOL)[0].trim(); - } - return raw.trim(); + const exp = /arcdata\s*(\d*.\d*.\d*)/; + return exp.exec(raw)?.pop(); } async function executeAzCommand(command: string, args: string[], additionalEnvVars: azExt.AdditionalEnvVars = {}): Promise { @@ -285,36 +518,13 @@ async function executeAzCommand(command: string, args: string[], additionalEnvVa return executeCommand(command, args, additionalEnvVars); } -// Commented the install/update prompts out until DoNotAskAgain is implemented -// async function setConfig(key: string, value: string): Promise { -// const config = vscode.workspace.getConfiguration(azConfigSection); -// await config.update(key, value, vscode.ConfigurationTarget.Global); -// } - -/** - * Find user's local Azure CLI. Execute az --version and parse out the version number. - * If an update is needed, prompt the user to update via link. Return the AzTool. - * Currently commented out because Don't Prompt Again is not properly implemented. - */ -async function findSpecificAz(): Promise { - const path = await ((process.platform === 'win32') ? searchForCmd('az.cmd') : searchForCmd('az')); - const versionOutput = await executeAzCommand(`"${path}"`, ['--version']); - const version = parseArcExtensionVersion(versionOutput.stdout); - const semVersion = new SemVer(version); - //let response: string | undefined; - - if (LATEST_AZ_ARC_EXTENSION_VERSION.compare(semVersion) === 1) { - // If there is a greater version of az arc extension available, prompt to update - // Commented the install/update prompts out until DoNotAskAgain is implemented - // const responses = [loc.askLater, loc.doNotAskAgain]; - // response = await vscode.window.showInformationMessage(loc.requiredArcDataVersionNotAvailable(latestAzArcExtensionVersion, version), ...responses); - // if (response === loc.doNotAskAgain) { - // await setConfig(azRequiredUpdateKey, AzDeployOption.dontPrompt); - // } - } else if (LATEST_AZ_ARC_EXTENSION_VERSION.compare(semVersion) === -1) { - // Current version should not be greater than latest version - // Commented the install/update prompts out until DoNotAskAgain is implemented - // vscode.window.showErrorMessage(loc.unsupportedArcDataVersion(latestAzArcExtensionVersion, version)); - } - return new AzTool(path, version); +function getAzConfig(key: string): AzDeployOption | undefined { + const config = vscode.workspace.getConfiguration(azConfigSection); + const value = config.get(key); + return value; +} + +async function setAzConfig(key: string, value: string): Promise { + const config = vscode.workspace.getConfiguration(azConfigSection); + await config.update(key, value, vscode.ConfigurationTarget.Global); } diff --git a/extensions/azcli/src/azReleaseInfo.ts b/extensions/azcli/src/azReleaseInfo.ts new file mode 100644 index 0000000000..2c7581fb28 --- /dev/null +++ b/extensions/azcli/src/azReleaseInfo.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +interface PlatformReleaseInfo { + version: string; // "20.0.1" + link?: string; // "https://aka.ms/az-msi" +} + +export interface AzReleaseInfo { + win32: PlatformReleaseInfo, + darwin: PlatformReleaseInfo, + linux: PlatformReleaseInfo +} diff --git a/extensions/azcli/src/constants.ts b/extensions/azcli/src/constants.ts index 79e72669a6..6da80d0926 100644 --- a/extensions/azcli/src/constants.ts +++ b/extensions/azcli/src/constants.ts @@ -6,11 +6,15 @@ // config setting keys export const azConfigSection: string = 'azcli'; export const debugConfigKey = 'logDebugInfo'; -export const azRequiredUpdateKey: string = 'requiredUpdate'; - +export const azArcdataInstallKey: string = 'arcdataInstall'; +export const azArcdataUpdateKey: string = 'arcdataUpdate'; +export const azCliInstallKey: string = 'azInstall'; // context keys && memento keys export const azFound = 'az.found'; +export const azArcFound = 'az.arcfound'; // other constants +export const azHostname = 'https://aka.ms'; +export const azUri = 'installazurecliwindows'; export const latestAzArcExtensionVersion = '1.0.0'; diff --git a/extensions/azcli/src/extension.ts b/extensions/azcli/src/extension.ts index ee439b1ac4..b3754f72a8 100644 --- a/extensions/azcli/src/extension.ts +++ b/extensions/azcli/src/extension.ts @@ -7,16 +7,26 @@ import * as azExt from 'az-ext'; import * as rd from 'resource-deployment'; import * as vscode from 'vscode'; import { getExtensionApi } from './api'; -import { findAz } from './az'; +import { checkAndInstallAz } from './az'; import { ArcControllerConfigProfilesOptionsSource } from './providers/arcControllerConfigProfilesOptionsSource'; import { AzToolService } from './services/azToolService'; export async function activate(context: vscode.ExtensionContext): Promise { const azToolService = new AzToolService(); + vscode.commands.registerCommand('az.install', async () => { + azToolService.localAz = await checkAndInstallAz(true /* userRequested */); + }); - azToolService.localAz = await findAz(); + // Don't block on this since we want the extension to finish activating without needing user input + const localAzDiscovered = checkAndInstallAz() // install if not installed and user wants it. + .then(async azTool => { + if (azTool !== undefined) { + azToolService.localAz = azTool; + } + return azTool; + }); - const azApi = getExtensionApi(azToolService); + const azApi = getExtensionApi(azToolService, localAzDiscovered); // register option source(s) const rdApi = vscode.extensions.getExtension(rd.extension.name)?.exports; diff --git a/extensions/azcli/src/localizedConstants.ts b/extensions/azcli/src/localizedConstants.ts index 322e14c219..c58c321838 100644 --- a/extensions/azcli/src/localizedConstants.ts +++ b/extensions/azcli/src/localizedConstants.ts @@ -5,11 +5,13 @@ import * as nls from 'vscode-nls'; import { getErrorMessage } from './common/utils'; +import { azCliInstallKey, azConfigSection } from './constants'; +// import { azCliInstallKey } from './constants'; const localize = nls.loadMessageBundle(); export const az = localize('az.az', "Azure CLI"); export const searchingForAz = localize('az.searchingForAz', "Searching for existing Azure CLI installation..."); -export const foundExistingAz = (path: string, version: string): string => localize('az.foundExistingAz', "Found existing Azure CLI installation of version (v{0}) at path:{1}", version, path); +export const foundExistingAz = (path: string, versionAz: string, versionArc: string): string => localize('az.foundExistingAz', "Found existing Azure CLI installation of version (v{0}) at path:{1} with arcdata version: {2}.", versionAz, path, versionArc); export const downloadingProgressMb = (currentMb: string, totalMb: string): string => localize('az.downloadingProgressMb', "Downloading ({0} / {1} MB)", currentMb, totalMb); export const downloadFinished = localize('az.downloadFinished', "Download finished"); export const downloadingTo = (name: string, url: string, location: string): string => localize('az.downloadingTo', "Downloading {0} from {1} to {2}", name, url, location); @@ -26,10 +28,47 @@ export const noReleaseVersion = (platform: string, releaseInfo: string): string export const noDownloadLink = (platform: string, releaseInfo: string): string => localize('az.noDownloadLink', "No download link available for platform '{0}'\nRelease info: ${1}", platform, releaseInfo); export const failedToParseReleaseInfo = (url: string, fileContents: string, err: any): string => localize('az.failedToParseReleaseInfo', "Failed to parse the JSON of contents at: {0}.\nFile contents:\n{1}\nError: {2}", url, fileContents, getErrorMessage(err)); export const endpointOrNamespaceRequired = localize('az.endpointOrNamespaceRequired', "Either an endpoint or a namespace must be specified"); -export const arcdataExtensionNotInstalled = localize('az.arcdataExtensionNotInstalled', "This extension requires the Azure CLI extension 'arcdata' to be installed. Install the latest version manually from [here](https://docs.microsoft.com/azure/azure-arc/data/install-arcdata-extension) and then restart Azure Data Studio."); -export const noAzureCLI = localize('az.noAzureCLI', "No Azure CLI is available. Install the latest version manually from [here](https://docs.microsoft.com/cli/azure/install-azure-cli) and then restart Azure Data Studio."); -export const requiredArcDataVersionNotAvailable = (requiredVersion: string, currentVersion: string): string => localize('az.requiredVersionNotAvailable', "This extension requires the Azure CLI extension 'arcdata' version >= {0} to be installed, but the current version available is only {1}. Install the correct version manually from [here](https://docs.microsoft.com/azure/azure-arc/data/install-arcdata-extension) and then restart Azure Data Studio.", requiredVersion, currentVersion); -export const unsupportedArcDataVersion = (requiredVersion: string, currentVersion: string): string => localize('az.unsupportedArcDataVersion', "Your downloaded version {1} of the Azure CLI extension 'arcdata' is not yet supported. The latest version is is {0}. Install the correct version manually from [here](https://docs.microsoft.com/azure/azure-arc/data/install-arcdata-extension) and then restart Azure Data Studio.", requiredVersion, currentVersion); +export const arcdataExtensionNotInstalled = localize('az.arcdataExtensionNotInstalled', "This extension requires the Azure CLI extension 'arcdata' to be installed. Install the latest version using instructions from [here](https://docs.microsoft.com/azure/azure-arc/data/install-arcdata-extension)."); +export const noAzureCLI = localize('az.noAzureCLI', "No Azure CLI is available. Install the latest version manually from [here](https://docs.microsoft.com/cli/azure/install-azure-cli) and then restart Azure Studio."); +export const requiredArcDataVersionNotAvailable = (requiredVersion: string, currentVersion: string): string => localize('az.requiredVersionNotAvailable', "This extension requires the Azure CLI extension 'arcdata' version >= {0} to be installed, but the current version available is only {1}. Install the correct version using instructions from [here](https://docs.microsoft.com/azure/azure-arc/data/install-arcdata-extension).", requiredVersion, currentVersion); +export const unsupportedArcDataVersion = (requiredVersion: string, currentVersion: string): string => localize('az.unsupportedArcDataVersion', "Your downloaded version {1} of the Azure CLI extension 'arcdata' is not yet supported. The latest version is is {0}. Install the correct version using instructions from [here](https://docs.microsoft.com/azure/azure-arc/data/install-arcdata-extension).", requiredVersion, currentVersion); export const doNotAskAgain = localize('az.doNotAskAgain', "Don't Ask Again"); export const askLater = localize('az.askLater', "Ask Later"); export const azOutputParseErrorCaught = (command: string): string => localize('az.azOutputParseErrorCaught', "An error occurred while parsing the output of az command: {0}. The output is not JSON.", command); +export const parseVersionError = localize('az.parseVersionError', "An error occurred while parsing the output of az --version."); +export const installingAz = localize('az.installingAz', "Installing Azure CLI..."); +export const installingArcdata = localize('az.installingArcdata', "Installing the Azure CLI arcdata extension..."); +export const updatingAz = localize('az.updatingAz', "Updating Azure CLI..."); +export const azInstalled = localize('az.azInstalled', "Azure CLI was successfully installed. Restarting Azure Studio is required to complete configuration - features will not be activated until this is done."); +export const arcdataInstalled = localize('az.arcdataInstalled', "The Azure CLI arcdata extension was successfully installed. Restarting Azure Studio is required to complete configuration - features will not be activated until this is done."); +export const yes = localize('az.yes', "Yes"); +export const no = localize('az.no', "No"); +export const accept = localize('az.accept', "Accept"); +export const decline = localize('az.decline', "Decline"); +export const checkingLatestAzVersion = localize('az.checkingLatestAzVersion', "Checking for latest available version of Azure CLI"); +export const foundAzVersionToUpdateTo = (newVersion: string, currentVersion: string): string => localize('az.versionForUpdate', "Found version: {0} that Azure CLI can be updated to from current version: {1}.", newVersion, currentVersion); +export const latestAzVersionAvailable = (version: string): string => localize('az.latestAzVersionAvailable', "Latest available Azure CLI version: {0}.", version); +export const couldNotFindAz = (err: any): string => localize('az.couldNotFindAz', "Could not find Azure CLI. Error: {0}", err.message ?? err); +export const couldNotFindAzArc = (err: any): string => localize('az.couldNotFindAzArc', "Could not find the Azure CLI arcdata extension. Error: {0}", err.message ?? err); +export const currentlyInstalledVersionIsLatest = (currentVersion: string): string => localize('az.currentlyInstalledVersionIsLatest', "Currently installed version of Azure CLI: {0} is same or newer than any other version available", currentVersion); +export const promptForAzInstall = localize('az.couldNotFindAzWithPrompt', "Could not find Azure CLI, install it now? If not then some features will not be able to function."); +export const promptForAzInstallLog = promptLog(promptForAzInstall); +export const promptForArcdataInstall = localize('az.couldNotFindArcdataWithPrompt', "Could not find the Azure CLI arcdata extension, install it now? If not then some features will not be able to function."); +export const promptForArcdataInstallLog = promptLog(promptForArcdataInstall); +export const promptForAzUpdate = (version: string): string => localize('az.promptForAzUpdate', "A new version of Azure CLI ( {0} ) is available, do you wish to update to it now?", version); +export const promptForRequiredAzUpdate = (requiredVersion: string, latestVersion: string): string => localize('az.promptForRequiredAzUpdate', "This extension requires Azure CLI >= {0} to be installed, do you wish to update to the latest version ({1}) now? If you do not then some functionality may not work.", requiredVersion, latestVersion); +export const promptForAzUpdateLog = (version: string): string => promptLog(promptForAzUpdate(version)); +export const promptForRequiredAzUpdateLog = (requiredVersion: string, latestVersion: string): string => promptLog(promptForRequiredAzUpdate(requiredVersion, latestVersion)); +export const missingRequiredVersion = (requiredVersion: string): string => localize('az.missingRequiredVersion', "Azure CLI >= {0} is required for this feature. Run the 'Azure CLI: Check for Update' command to install this and then try again.", requiredVersion); +export const installError = (err: any): string => localize('az.installError', "Error installing Azure CLI and arcdata extension: {0}", err.message ?? err); +export const updateError = (err: any): string => localize('az.updateError', "Error updating Azure CLI: {0}", err.message ?? err); +export const noAz = localize('az.noAz', "No Azure CLI is available, run the command 'Azure CLI: Install' to enable the features that require it."); +export const noAzArc = localize('az.noAzArc', "No Azure CLI arcdata extension is available."); +export const noAzWithLink = localize('az.noAzWithLink', "No Azure CLI is available, [install the Azure CLI](command:az.install) to enable the features that require it."); +export const skipInstall = (config: string): string => localize('az.skipInstall', "Skipping installation of Azure CLI and arcdata extension, since the operation was not user requested and config option: {0}.{1} is {2}", azConfigSection, azCliInstallKey, config); +export const azUserSettingRead = (configName: string, configValue: string): string => localize('az.azUserSettingReadLog', "Azure CLI user setting: {0}.{1} read, value: {2}", azConfigSection, configName, configValue); +export const azUserSettingUpdated = (configName: string, configValue: string): string => localize('az.azUserSettingUpdatedLog', "Azure CLI user setting: {0}.{1} updated, newValue: {2}", azConfigSection, configName, configValue); +export const userResponseToInstallPrompt = (response: string | undefined): string => localize('az.userResponseInstall', "User Response on prompt to install Azure CLI: {0}", response); +export const userResponseToUpdatePrompt = (response: string | undefined): string => localize('az.userResponseUpdate', "User Response on prompt to update Azure CLI: {0}", response); +export const userRequestedInstall = localize('az.userRequestedInstall', "User requested to install Azure CLI and arcdata extension using 'Azure CLI: Install' command"); +export const updateCheckSkipped = localize('az.updateCheckSkipped', "No check for new Azure CLI version availability performed as Azure CLI was not found to be installed"); diff --git a/extensions/azcli/src/test/az.test.ts b/extensions/azcli/src/test/az.test.ts new file mode 100644 index 0000000000..19c1d37466 --- /dev/null +++ b/extensions/azcli/src/test/az.test.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as should from 'should'; +import * as sinon from 'sinon'; +import * as childProcess from '../common/childProcess'; +import * as az from '../az'; + +describe('az', function () { + afterEach(function (): void { + sinon.restore(); + }); + describe('azTool', function (): void { + const azTool = new az.AzTool('my path', '2.26.0', '1.0.0'); + let executeCommandStub: sinon.SinonStub; + const namespace = 'arc'; + const name = 'arcdc'; + + beforeEach(function (): void { + executeCommandStub = sinon.stub(childProcess, 'executeCommand').resolves({ stdout: '{}', stderr: '' }); + }); + + describe('arcdata', function (): void { + describe('dc', function (): void { + describe('endpoint', async function (): Promise { + it('list', async function (): Promise { + await azTool.arcdata.dc.endpoint.list(namespace); + verifyExecuteCommandCalledWithArgs(['arcdata', 'dc', 'endpoint', 'list', '--k8s-namespace', namespace, '--use-k8s']); + }); + }); + describe('config', async function (): Promise { + it('list', async function (): Promise { + await azTool.arcdata.dc.config.list(); + verifyExecuteCommandCalledWithArgs(['arcdata', 'dc', 'config', 'list']); + }); + it('show', async function (): Promise { + await azTool.arcdata.dc.config.show(namespace); + verifyExecuteCommandCalledWithArgs(['arcdata', 'dc', 'config', 'show', '--k8s-namespace', namespace, '--use-k8s']); + }); + }); + }); + }); + + describe('postgres', function (): void { + describe('arc-server', function (): void { + it('delete', async function (): Promise { + await azTool.postgres.arcserver.delete(name, namespace); + verifyExecuteCommandCalledWithArgs(['postgres', 'arc-server', 'delete', name, '--k8s-namespace', namespace]); + }); + it('list', async function (): Promise { + await azTool.postgres.arcserver.list(namespace); + verifyExecuteCommandCalledWithArgs(['postgres', 'arc-server', 'list', '--k8s-namespace', namespace]); + }); + it('show', async function (): Promise { + await azTool.postgres.arcserver.show(name, namespace); + verifyExecuteCommandCalledWithArgs(['postgres', 'arc-server', 'show', name, '--k8s-namespace', namespace]); + }); + it('edit', async function (): Promise { + const args = { + adminPassword: true, + coresLimit: 'myCoresLimit', + coresRequest: 'myCoresRequest', + engineSettings: 'myEngineSettings', + extensions: 'myExtensions', + memoryLimit: 'myMemoryLimit', + memoryRequest: 'myMemoryRequest', + noWait: true, + port: 1337, + replaceEngineSettings: true, + workers: 2 + }; + await azTool.postgres.arcserver.edit(name, args, namespace); + verifyExecuteCommandCalledWithArgs([ + 'postgres', 'arc-server', 'edit', + name, + '--admin-password', + args.coresLimit, + args.coresRequest, + args.engineSettings, + args.extensions, + args.memoryLimit, + args.memoryRequest, + '--no-wait', + args.port.toString(), + '--replace-engine-settings', + args.workers.toString()]); + }); + it('edit no optional args', async function (): Promise { + await azTool.postgres.arcserver.edit(name, {}, namespace); + verifyExecuteCommandCalledWithArgs([ + 'postgres', 'arc-server', 'edit', + name]); + verifyExecuteCommandCalledWithoutArgs([ + '--admin-password', + '--cores-limit', + '--cores-request', + '--engine-settings', + '--extensions', + '--memory-limit', + '--memory-request', + '--no-wait', + '--port', + '--replace-engine-settings', + '--workers']); + }); + }); + }); + describe('sql', function (): void { + describe('mi-arc', function (): void { + it('delete', async function (): Promise { + await azTool.sql.miarc.delete(name, namespace); + verifyExecuteCommandCalledWithArgs(['sql', 'mi-arc', 'delete', name, '--k8s-namespace', namespace, '--use-k8s']); + }); + it('list', async function (): Promise { + await azTool.sql.miarc.list(namespace); + verifyExecuteCommandCalledWithArgs(['sql', 'mi-arc', 'list', '--k8s-namespace', namespace, '--use-k8s']); + }); + it('show', async function (): Promise { + await azTool.sql.miarc.show(name, namespace); + verifyExecuteCommandCalledWithArgs(['sql', 'mi-arc', 'show', name, '--k8s-namespace', namespace, '--use-k8s']); + }); + }); + }); + + it('version', async function (): Promise { + executeCommandStub.resolves({ stdout: '1.0.0', stderr: '' }); + await azTool.version(); + verifyExecuteCommandCalledWithArgs(['--version']); + }); + + /** + * Verifies that the specified args were included in the call to executeCommand + * @param args The args to check were included in the execute command call + */ + function verifyExecuteCommandCalledWithArgs(args: string[], callIndex = 0): void { + const commandArgs = executeCommandStub.args[callIndex][1] as string[]; + args.forEach(arg => should(commandArgs).containEql(arg)); + } + + /** + * Verifies that the specified args weren't included in the call to executeCommand + * @param args The args to check weren't included in the execute command call + */ + function verifyExecuteCommandCalledWithoutArgs(args: string[]): void { + const commandArgs = executeCommandStub.args[0][1] as string[]; + args.forEach(arg => should(commandArgs).not.containEql(arg)); + } + }); +}); diff --git a/extensions/azcli/src/test/azdata.test.ts b/extensions/azcli/src/test/azcli.test.ts similarity index 97% rename from extensions/azcli/src/test/azdata.test.ts rename to extensions/azcli/src/test/azcli.test.ts index 0631dd9026..a63c95f0c3 100644 --- a/extensions/azcli/src/test/azdata.test.ts +++ b/extensions/azcli/src/test/azcli.test.ts @@ -13,10 +13,10 @@ describe('az', function () { sinon.restore(); }); describe('azTool', function (): void { - const azTool = new azdata.AzTool('C:/Program Files (x86)/Microsoft SDKs/Azure/CLI2/wbin/az.cmd', '2.26.0'); + const azTool = new azdata.AzTool('my path', '2.26.0', '1.0.0'); let executeCommandStub: sinon.SinonStub; - const namespace = 'arc4'; - const name = 'cy-dc-4'; + const namespace = 'arc'; + const name = 'dc'; beforeEach(function (): void { executeCommandStub = sinon.stub(childProcess, 'executeCommand').resolves({ stdout: '{}', stderr: '' }); diff --git a/extensions/azcli/src/test/index.ts b/extensions/azcli/src/test/index.ts index ea03898d6b..dc61672983 100644 --- a/extensions/azcli/src/test/index.ts +++ b/extensions/azcli/src/test/index.ts @@ -6,7 +6,7 @@ import * as path from 'path'; const testRunner = require('vscodetestcover'); -const suite = 'azdata Extension Tests'; +const suite = 'azcli Extension Tests'; const mochaOptions: any = { ui: 'bdd', diff --git a/extensions/azcli/src/test/services/azdataToolService.test.ts b/extensions/azcli/src/test/services/azdataToolService.test.ts deleted file mode 100644 index 516dae0b34..0000000000 --- a/extensions/azcli/src/test/services/azdataToolService.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the Source EULA. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as should from 'should'; -import { AzTool } from '../../az'; -import { AzToolService } from '../../services/azToolService'; - -describe('azToolService', function (): void { - it('Tool should be set correctly', async function (): Promise { - const service = new AzToolService(); - should(service.localAz).be.undefined(); - service.localAz = new AzTool('my path', '1.0.0'); - should(service).not.be.undefined(); - }); -}); diff --git a/extensions/azcli/src/typings/az-ext.d.ts b/extensions/azcli/src/typings/az-ext.d.ts index e3df7e6efd..51cc8ef700 100644 --- a/extensions/azcli/src/typings/az-ext.d.ts +++ b/extensions/azcli/src/typings/az-ext.d.ts @@ -327,11 +327,17 @@ declare module 'az-ext' { }, getPath(): Promise, /** - * The semVersion corresponding to this installation of az. version() method should have been run + * The semVersion corresponding to this installation of the Azure CLI. version() method should have been run * before fetching this value to ensure that correct value is returned. This is almost always correct unless * Az has gotten reinstalled in the background after this IAzApi object was constructed. */ - getSemVersion(): Promise, + getSemVersionAz(): Promise, + /** + * The semVersion corresponding to this installation of the Azure CLI arcdata extension. version() method should + * have been run before fetching this value to ensure that correct value is returned. This is almost always + * correct unless az arcdata has gotten reinstalled in the background after this IAzApi object was constructed. + */ + getSemVersionArc(): Promise, version(): Promise> }