From b54beb6e7a00f435aeddcfedc08317922ec5c75b Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Wed, 7 Apr 2021 10:53:08 -0700 Subject: [PATCH] Add required minimum version for azdata extension (#15010) (#15015) * Add check for minimum required azdata version * cleanup * remove unused * comment * param comment * Fix tests --- extensions/azdata/src/api.ts | 74 ++++++++----- extensions/azdata/src/azdata.ts | 111 ++++++++++++++------ extensions/azdata/src/localizedConstants.ts | 5 +- extensions/azdata/src/test/api.test.ts | 4 +- extensions/azdata/src/test/azdata.test.ts | 4 +- 5 files changed, 133 insertions(+), 65 deletions(-) diff --git a/extensions/azdata/src/api.ts b/extensions/azdata/src/api.ts index 16d816bd27..4b35d8c7d4 100644 --- a/extensions/azdata/src/api.ts +++ b/extensions/azdata/src/api.ts @@ -5,13 +5,26 @@ import * as azdataExt from 'azdata-ext'; import * as vscode from 'vscode'; -import { IAzdataTool, isEulaAccepted, promptForEula } from './azdata'; +import { IAzdataTool, isEulaAccepted, MIN_AZDATA_VERSION, promptForEula } from './azdata'; import Logger from './common/logger'; import { NoAzdataError } from './common/utils'; import * as constants from './constants'; import * as loc from './localizedConstants'; import { AzdataToolService } from './services/azdataToolService'; +/** + * Validates that : + * - Azdata is installed + * - The Azdata version is >= the minimum required version + * - The Azdata CLI has been accepted + * @param azdata The azdata tool to check + * @param eulaAccepted Whether the Azdata CLI EULA has been accepted + */ +async function validateAzdata(azdata: IAzdataTool | undefined, eulaAccepted: boolean): Promise { + throwIfNoAzdataOrEulaNotAccepted(azdata, eulaAccepted); + await throwIfRequiredVersionMissing(azdata); +} + export function throwIfNoAzdataOrEulaNotAccepted(azdata: IAzdataTool | undefined, eulaAccepted: boolean): asserts azdata { throwIfNoAzdata(azdata); if (!eulaAccepted) { @@ -20,6 +33,13 @@ export function throwIfNoAzdataOrEulaNotAccepted(azdata: IAzdataTool | undefined } } +export async function throwIfRequiredVersionMissing(azdata: IAzdataTool): Promise { + const currentVersion = await azdata.getSemVersion(); + if (currentVersion.compare(MIN_AZDATA_VERSION) < 0) { + throw new Error(loc.missingRequiredVersion(MIN_AZDATA_VERSION.raw)); + } +} + export function throwIfNoAzdata(localAzdata: IAzdataTool | undefined): asserts localAzdata { if (!localAzdata) { Logger.log(loc.noAzdata); @@ -57,26 +77,26 @@ export function getAzdataApi(localAzdataDiscovered: Promise { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.create(namespace, name, connectivityMode, resourceGroup, location, subscription, profileName, storageClass, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.dc.create(namespace, name, connectivityMode, resourceGroup, location, subscription, profileName, storageClass, additionalEnvVars, azdataContext); }, endpoint: { list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, azdataContext?: string) => { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.endpoint.list(additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.dc.endpoint.list(additionalEnvVars, azdataContext); } }, config: { list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, azdataContext?: string) => { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.config.list(additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.dc.config.list(additionalEnvVars, azdataContext); }, show: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, azdataContext?: string) => { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.config.show(additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.dc.config.show(additionalEnvVars, azdataContext); } } }, @@ -84,18 +104,18 @@ export function getAzdataApi(localAzdataDiscovered: Promise { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.delete(name, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.postgres.server.delete(name, additionalEnvVars, azdataContext); }, list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, azdataContext?: string) => { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.list(additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.postgres.server.list(additionalEnvVars, azdataContext); }, show: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, azdataContext?: string) => { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.show(name, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.postgres.server.show(name, additionalEnvVars, azdataContext); }, edit: async ( name: string, @@ -115,8 +135,8 @@ export function getAzdataApi(localAzdataDiscovered: Promise { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.edit(name, args, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.postgres.server.edit(name, args, additionalEnvVars, azdataContext); } } }, @@ -124,18 +144,18 @@ export function getAzdataApi(localAzdataDiscovered: Promise { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.delete(name, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.sql.mi.delete(name, additionalEnvVars, azdataContext); }, list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, azdataContext?: string) => { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.list(additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.sql.mi.list(additionalEnvVars, azdataContext); }, show: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, azdataContext?: string) => { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.show(name, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.sql.mi.show(name, additionalEnvVars, azdataContext); }, edit: async ( name: string, @@ -150,8 +170,8 @@ export function getAzdataApi(localAzdataDiscovered: Promise { await localAzdataDiscovered; - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.edit(name, args, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.arc.sql.mi.edit(name, args, additionalEnvVars, azdataContext); } } } @@ -162,8 +182,8 @@ export function getAzdataApi(localAzdataDiscovered: Promise { - throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.login(endpointOrNamespace, username, password, additionalEnvVars, azdataContext); + await validateAzdata(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata!.login(endpointOrNamespace, username, password, additionalEnvVars, azdataContext); }, getSemVersion: async () => { await localAzdataDiscovered; diff --git a/extensions/azdata/src/azdata.ts b/extensions/azdata/src/azdata.ts index 98fb237aa7..31c5631222 100644 --- a/extensions/azdata/src/azdata.ts +++ b/extensions/azdata/src/azdata.ts @@ -17,6 +17,11 @@ import { getErrorMessage, NoAzdataError, searchForCmd } from './common/utils'; import { azdataAcceptEulaKey, azdataConfigSection, azdataFound, azdataInstallKey, azdataUpdateKey, debugConfigKey, eulaAccepted, eulaUrl, microsoftPrivacyStatementUrl } from './constants'; import * as loc from './localizedConstants'; +/** + * The minimum required azdata CLI version for this extension to function properly + */ +export const MIN_AZDATA_VERSION = new SemVer('20.3.2'); + export const enum AzdataDeployOption { dontPrompt = 'dontPrompt', prompt = 'prompt' @@ -367,8 +372,22 @@ export async function checkAndInstallAzdata(userRequested: boolean = false): Pro export async function checkAndUpdateAzdata(currentAzdata?: IAzdataTool, userRequested: boolean = false): Promise { if (currentAzdata !== undefined) { const newSemVersion = await discoverLatestAvailableAzdataVersion(); - if (newSemVersion.compare(await currentAzdata.getSemVersion()) === 1) { - Logger.log(loc.foundAzdataVersionToUpdateTo(newSemVersion.raw, (await currentAzdata.getSemVersion()).raw)); + const currentSemVersion = await currentAzdata.getSemVersion(); + Logger.log(loc.foundAzdataVersionToUpdateTo(newSemVersion.raw, currentSemVersion.raw)); + if (MIN_AZDATA_VERSION.compare(currentSemVersion) === 1) { + if (newSemVersion.compare(MIN_AZDATA_VERSION) >= 0) { + return await promptToUpdateAzdata(newSemVersion.raw, userRequested, true); + } else { + // This should never happen - it means that the currently available version to download + // is < the version we require. If this was to happen it'd imply something is wrong with + // the version JSON or the minimum required version. + // Regardless, there's nothing we can do and so we just bail out at this point and tell the user + // they have to install it manually (hopefully it's available and wasn't a publishing mistake) + vscode.window.showInformationMessage(loc.requiredVersionNotAvailable(MIN_AZDATA_VERSION.raw, newSemVersion.raw)); + Logger.log(loc.requiredVersionNotAvailable(newSemVersion.raw, currentSemVersion.raw)); + } + } + else if (newSemVersion.compare(currentSemVersion) === 1) { return await promptToUpdateAzdata(newSemVersion.raw, userRequested); } else { Logger.log(loc.currentlyInstalledVersionIsLatest((await currentAzdata.getSemVersion()).raw)); @@ -429,39 +448,65 @@ async function promptToInstallAzdata(userRequested: boolean = false): Promise { - let response: string | undefined = loc.yes; - const config = getConfig(azdataUpdateKey); - if (userRequested) { - Logger.show(); - Logger.log(loc.userRequestedUpdate); - } - if (config === AzdataDeployOption.dontPrompt && !userRequested) { - Logger.log(loc.skipUpdate(config)); - return false; - } - const responses = userRequested - ? [loc.yes, loc.no] - : [loc.yes, loc.askLater, loc.doNotAskAgain]; - if (config === AzdataDeployOption.prompt) { - Logger.log(loc.promptForAzdataUpdateLog(newVersion)); - response = await vscode.window.showInformationMessage(loc.promptForAzdataUpdate(newVersion), ...responses); +async function promptToUpdateAzdata(newVersion: string, userRequested: boolean = false, required = false): Promise { + if (required) { + let response: string | undefined = loc.yes; + + const responses = [loc.yes, loc.no]; + Logger.log(loc.promptForRequiredAzdataUpdateLog(MIN_AZDATA_VERSION.raw, newVersion)); + response = await vscode.window.showInformationMessage(loc.promptForRequiredAzdataUpdate(MIN_AZDATA_VERSION.raw, newVersion), ...responses); Logger.log(loc.userResponseToUpdatePrompt(response)); - } - if (response === loc.doNotAskAgain) { - await setConfig(azdataUpdateKey, AzdataDeployOption.dontPrompt); - } else if (response === loc.yes) { - try { - await updateAzdata(); - vscode.window.showInformationMessage(loc.azdataUpdated(newVersion)); - Logger.log(loc.azdataUpdated(newVersion)); - 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.updateError(err)); - Logger.log(loc.updateError(err)); + if (response === loc.yes) { + try { + await updateAzdata(); + vscode.window.showInformationMessage(loc.azdataUpdated(newVersion)); + Logger.log(loc.azdataUpdated(newVersion)); + 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.updateError(err)); + Logger.log(loc.updateError(err)); + } + } + } else { + vscode.window.showWarningMessage(loc.missingRequiredVersion(MIN_AZDATA_VERSION.raw)); + } + } else { + let response: string | undefined = loc.yes; + const config = getConfig(azdataUpdateKey); + if (userRequested) { + Logger.show(); + Logger.log(loc.userRequestedUpdate); + } + if (config === AzdataDeployOption.dontPrompt && !userRequested) { + Logger.log(loc.skipUpdate(config)); + return false; + } + const responses = userRequested + ? [loc.yes, loc.no] + : [loc.yes, loc.askLater, loc.doNotAskAgain]; + if (config === AzdataDeployOption.prompt) { + Logger.log(loc.promptForAzdataUpdateLog(newVersion)); + response = await vscode.window.showInformationMessage(loc.promptForAzdataUpdate(newVersion), ...responses); + Logger.log(loc.userResponseToUpdatePrompt(response)); + } + if (response === loc.doNotAskAgain) { + await setConfig(azdataUpdateKey, AzdataDeployOption.dontPrompt); + } else if (response === loc.yes) { + try { + await updateAzdata(); + vscode.window.showInformationMessage(loc.azdataUpdated(newVersion)); + Logger.log(loc.azdataUpdated(newVersion)); + 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.updateError(err)); + Logger.log(loc.updateError(err)); + } } } } diff --git a/extensions/azdata/src/localizedConstants.ts b/extensions/azdata/src/localizedConstants.ts index 1dbf4d7166..1775d991b4 100644 --- a/extensions/azdata/src/localizedConstants.ts +++ b/extensions/azdata/src/localizedConstants.ts @@ -38,8 +38,11 @@ export const promptLog = (logEntry: string) => localize('azdata.promptLog', "Pro export const promptForAzdataInstall = localize('azdata.couldNotFindAzdataWithPrompt', "Could not find Azure Data CLI, install it now? If not then some features will not be able to function."); export const promptForAzdataInstallLog = promptLog(promptForAzdataInstall); export const promptForAzdataUpdate = (version: string): string => localize('azdata.promptForAzdataUpdate', "A new version of Azure Data CLI ( {0} ) is available, do you wish to update to it now?", version); +export const promptForRequiredAzdataUpdate = (requiredVersion: string, latestVersion: string): string => localize('azdata.promptForRequiredAzdataUpdate', "This extension requires Azure Data 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 requiredVersionNotAvailable = (requiredVersion: string, currentVersion: string): string => localize('azdata.requiredVersionNotAvailable', "This extension requires Azure Data CLI >= {0} to be installed, but the current version available is only {1}. Install the correct version manually from [here](https://docs.microsoft.com/sql/azdata/install/deploy-install-azdata) and then restart Azure Data Studio.", requiredVersion, currentVersion); export const promptForAzdataUpdateLog = (version: string): string => promptLog(promptForAzdataUpdate(version)); - +export const promptForRequiredAzdataUpdateLog = (requiredVersion: string, latestVersion: string): string => promptLog(promptForRequiredAzdataUpdate(requiredVersion, latestVersion)); +export const missingRequiredVersion = (requiredVersion: string): string => localize('azdata.missingRequiredVersion', "Azure Data CLI >= {0} is required for this extension to function, some features may not work correctly until that version or higher is installed.", requiredVersion); export const downloadError = localize('azdata.downloadError', "Error while downloading"); export const installError = (err: any): string => localize('azdata.installError', "Error installing Azure Data CLI: {0}", err.message ?? err); export const updateError = (err: any): string => localize('azdata.updateError', "Error updating Azure Data CLI: {0}", err.message ?? err); diff --git a/extensions/azdata/src/test/api.test.ts b/extensions/azdata/src/test/api.test.ts index 49054038cb..a044f33815 100644 --- a/extensions/azdata/src/test/api.test.ts +++ b/extensions/azdata/src/test/api.test.ts @@ -51,7 +51,7 @@ describe('api', function (): void { it('succeed when azdata present and EULA accepted', async function (): Promise { const mementoMock = TypeMoq.Mock.ofType(); mementoMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => true); - const azdataTool = new AzdataTool('', '1.0.0'); + const azdataTool = new AzdataTool('', '99.0.0'); const azdataToolService = new AzdataToolService(); azdataToolService.localAzdata = azdataTool; // Not using a mock here because it'll hang when resolving mocked objects @@ -60,7 +60,7 @@ describe('api', function (): void { sinon.stub(childProcess, 'executeCommand').callsFake(async (_command, args) => { // Version needs to be valid so it can be parsed correctly if (args[0] === '--version') { - return { stdout: `1.0.0`, stderr: '' }; + return { stdout: `99.0.0`, stderr: '' }; } console.log(args[0]); return { stdout: `{ }`, stderr: '' }; diff --git a/extensions/azdata/src/test/azdata.test.ts b/extensions/azdata/src/test/azdata.test.ts index e4b66d09c4..c8c60e200f 100644 --- a/extensions/azdata/src/test/azdata.test.ts +++ b/extensions/azdata/src/test/azdata.test.ts @@ -17,7 +17,7 @@ import { AzdataReleaseInfo } from '../azdataReleaseInfo'; import * as TypeMoq from 'typemoq'; import { eulaAccepted } from '../constants'; -const oldAzdataMock = new azdata.AzdataTool('/path/to/azdata', '0.0.0'); +const oldAzdataMock = new azdata.AzdataTool('/path/to/azdata', azdata.MIN_AZDATA_VERSION.raw); const currentAzdataMock = new azdata.AzdataTool('/path/to/azdata', '9999.999.999'); /** @@ -665,7 +665,7 @@ async function testDarwinSkippedUpdateDontPrompt() { async function testWin32SkippedUpdateDontPrompt() { sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); await azdata.checkAndUpdateAzdata(oldAzdataMock); - should(executeSudoCommandStub.notCalled).be.true('executeSudoCommand should not have been called'); + should(executeSudoCommandStub.notCalled).be.true(`executeSudoCommand should not have been called ${executeSudoCommandStub.getCalls().join(os.EOL)}`); } async function testLinuxSkippedUpdateDontPrompt() {