diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index 0283a7889b..72283ddd66 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -456,6 +456,7 @@ } }, "dependencies": { + "axios": "^0.27.2", "@microsoft/ads-extension-telemetry": "^1.1.5", "fast-glob": "^3.2.7", "fs-extra": "^5.0.0", diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 293e2df358..a83d8a7d3b 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -149,10 +149,16 @@ export const nameMustNotBeEmpty = localize('nameMustNotBeEmpty', "Name must not // Deploy export const SqlServerName = 'SQL server'; export const AzureSqlServerName = 'Azure SQL server'; +export const SqlServerDockerImageName = 'Microsoft SQL Server'; +export const AzureSqlDbFullDockerImageName = 'Azure SQL Database emulator Full'; +export const AzureSqlDbLiteDockerImageName = 'Azure SQL Database emulator Lite'; export const AzureSqlLogicalServerName = 'Azure SQL logical server'; export const selectPublishOption = localize('selectPublishOption', "Select where to publish the project to"); +export const defaultQuickPickItem = localize('defaultQuickPickItem', "Default - image defined as default in the container registry"); +export function dockerImagesPlaceHolder(name: string) { return localize('dockerImagesPlaceHolder', 'Use {0} on local arm64/Apple Silicon', name); } export function publishToExistingServer(name: string) { return localize('publishToExistingServer', "Publish to an existing {0}", name); } export function publishToDockerContainer(name: string) { return localize('publishToDockerContainer', "Publish to new {0} local development container", name); } +export const publishToAzureEmulator = localize('publishToAzureEmulator', "Publish to new Azure SQL Database emulator"); export const publishToNewAzureServer = localize('publishToNewAzureServer', "Publish to new Azure SQL logical server"); export const azureServerName = localize('azureServerName', "Azure SQL server name"); export const azureSubscription = localize('azureSubscription', "Azure subscription"); @@ -171,12 +177,14 @@ export function enterUser(name: string) { return localize('enterUser', "Enter {0 export function enterPassword(name: string) { return localize('enterPassword', "Enter {0} admin password", name); } export function confirmPassword(name: string) { return localize('confirmPassword', "Confirm {0} admin password", name); } export function selectBaseImage(name: string) { return localize('selectBaseImage', "Select the base {0} docker image", name); } +export function selectImageTag(name: string) { return localize('selectImageTag', "Select the image tag or press enter to use the default value", name); } export function invalidSQLPasswordMessage(name: string) { return localize('invalidSQLPassword', "{0} password doesn't meet the password complexity requirement. For more information see https://docs.microsoft.com/sql/relational-databases/security/password-policy", name); } export function passwordNotMatch(name: string) { return localize('passwordNotMatch', "{0} password doesn't match the confirmation password", name); } export const portMustBeNumber = localize('portMustNotBeNumber', "Port must a be number"); export const valueCannotBeEmpty = localize('valueCannotBeEmpty', "Value cannot be empty"); export const dockerImageLabelPrefix = 'source=sqldbproject'; export const dockerImageNamePrefix = 'sqldbproject'; +export const dockerImageDefaultTag = 'latest'; // Publish to Container export const eulaAgreementTemplate = localize({ key: 'eulaAgreementTemplate', comment: ['The placeholders are contents of the line and should not be translated.'] }, "I accept the {0}."); diff --git a/extensions/sql-database-projects/src/common/httpClient.ts b/extensions/sql-database-projects/src/common/httpClient.ts new file mode 100644 index 0000000000..af688a7efe --- /dev/null +++ b/extensions/sql-database-projects/src/common/httpClient.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; +import axios, { AxiosRequestConfig } from 'axios'; + +/** + * Class includes method for making http request + */ +export class HttpClient { + private static cache: Map = new Map(); + + /** + * Makes http GET request to the given url. If useCache is set to true, returns the result from cache if exists + * @param url url to make http GET request against + * @param useCache if true and result is already cached the cached value will be returned + * @returns result of http GET request + */ + public static async getRequest(url: string, useCache = false): Promise { + + if (useCache) { + if (HttpClient.cache.has(url)) { + return HttpClient.cache.get(url); + } + } + + const config: AxiosRequestConfig = { + headers: { + 'Content-Type': 'application/json' + }, + validateStatus: () => true // Never throw + }; + const response = await axios.get(url, config); + if (response.status !== 200) { + let errorMessage: string[] = []; + errorMessage.push(response.status.toString()); + errorMessage.push(response.statusText); + if (response.data?.error) { + errorMessage.push(`${response.data?.error?.code} : ${response.data?.error?.message}`); + } + throw new Error(errorMessage.join(os.EOL)); + } + + if (useCache) { + HttpClient.cache.set(url, response.data); + } + return response.data; + } +} diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index d3153d283f..80fdccd1eb 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -641,3 +641,47 @@ export function getWellKnownDatabaseSources(databaseSourceValues: string[]): str return Array.from(databaseSourceSet); } + +/** + * Returns SQL version number from docker image name which is in the beginning of the image name + * @param imageName docker image name + * @returns SQL server version + */ +export function findSqlVersionInImageName(imageName: string): number | undefined { + + // Regex to find the version in the beginning of the image name + // e.g. 2017-CU16-ubuntu, 2019-latest + const regex = new RegExp('^([0-9]+)[-].+$'); + + if (regex.test(imageName)) { + const finds = regex.exec(imageName); + if (finds) { + + // 0 is the full match and 1 is the number with pattern inside the first () + return +finds[1]; + } + } + return undefined; +} + +/** + * Returns SQL version number from target platform name + * @param targetPlatform target platform + * @returns SQL server version + */ +export function findSqlVersionInTargetPlatform(targetPlatform: string): number | undefined { + + // Regex to find the version in target platform + // e.g. SQL Server 2019 + const regex = new RegExp('([0-9]+)$'); + + if (regex.test(targetPlatform)) { + const finds = regex.exec(targetPlatform); + if (finds) { + + // 0 is the full match and 1 is the number with pattern inside the first () + return +finds[1]; + } + } + return undefined; +} diff --git a/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts b/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts index d75df6dfc8..417899263e 100644 --- a/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts +++ b/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts @@ -275,7 +275,8 @@ export async function launchCreateAzureServerQuickPick(project: Project, azureSq * Create flow for publishing a database to docker container using only VS Code-native APIs such as QuickPick */ export async function launchPublishToDockerContainerQuickpick(project: Project): Promise { - const name = uiUtils.getPublishServerName(project.getProjectTargetVersion()); + const target = project.getProjectTargetVersion(); + const name = uiUtils.getPublishServerName(target); let localDbSetting: ILocalDbSetting | undefined; // Deploy to docker selected let portNumber = await vscode.window.showInputBox({ @@ -321,10 +322,10 @@ export async function launchPublishToDockerContainerQuickpick(project: Project): return undefined; } - const baseImages = uiUtils.getDockerBaseImages(project.getProjectTargetVersion()); + const baseImages = uiUtils.getDockerBaseImages(target); const baseImage = await vscode.window.showQuickPick( baseImages.map(x => x.displayName), - { title: constants.selectBaseImage(name), ignoreFocusOut: true }); + { title: constants.selectBaseImage(name), ignoreFocusOut: true, placeHolder: uiUtils.getDockerImagePlaceHolder(target) }); // Return when user hits escape if (!baseImage) { @@ -332,19 +333,50 @@ export async function launchPublishToDockerContainerQuickpick(project: Project): } const imageInfo = baseImages.find(x => x.displayName === baseImage); + + if (!imageInfo) { + return undefined; + } + const eulaAccepted = await launchEulaQuickPick(imageInfo); if (!eulaAccepted) { return undefined; } + let imageTags = await uiUtils.getImageTags(imageInfo, target); + let imageTagsItems: vscode.QuickPickItem[] = imageTags.map(tag => { return { label: tag }; }); + + if (imageInfo.defaultTag) { + // move the default to be the first one in the list + const defaultIndex = imageTagsItems.findIndex(i => i.label === imageInfo.defaultTag); + if (defaultIndex > -1) { + imageTagsItems.splice(defaultIndex, 1); + } + // add default next to the default value + imageTagsItems.unshift({ label: imageInfo.defaultTag, description: constants.defaultQuickPickItem }); + } + const imageTag = await vscode.window.showQuickPick( + imageTagsItems, + { title: constants.selectImageTag(name), ignoreFocusOut: true }); + + if (!imageTag) { + return undefined; + } + + // Add the image tag if it's not the latest + let imageName = imageInfo.name; + if (imageTag && imageTag.label !== constants.dockerImageDefaultTag) { + imageName = `${imageName}:${imageTag.label}`; + } + localDbSetting = { serverName: constants.defaultLocalServerName, userName: constants.defaultLocalServerAdminName, dbName: project.projectFileName, password: password, port: +portNumber, - dockerBaseImage: imageInfo?.name || '', - dockerBaseImageEula: imageInfo?.agreementInfo?.link?.url || '' + dockerBaseImage: imageName, + dockerBaseImageEula: imageInfo.agreementInfo.link.url }; let deploySettings = await getPublishDatabaseSettings(project, false); @@ -360,7 +392,6 @@ export async function launchPublishToDockerContainerQuickpick(project: Project): // Get the database name from deploy settings localDbSetting.dbName = deploySettings.databaseName; - return { localDbSetting: localDbSetting, deploySettings: deploySettings, diff --git a/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts b/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts index f8ed94c3b3..40bb456245 100644 --- a/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts +++ b/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts @@ -213,9 +213,13 @@ export async function launchPublishTargetOption(project: Project): Promise { + let imageTags: string[] | undefined = []; + const versionToImageTags: Map = new Map(); + + try { + const imageTagsFromUrl = await HttpClient.getRequest(imageInfo?.tagsUrl, true); + if (imageTagsFromUrl?.tags) { + + // Create a map for version and tags and find the max version in the list + let defaultVersion: number = 0; + let maxVersionNumber: number = defaultVersion; + (imageTagsFromUrl.tags as string[]).forEach(imageTag => { + const version = utils.findSqlVersionInImageName(imageTag) || defaultVersion; + let tags = versionToImageTags.has(version) ? versionToImageTags.get(version) : []; + tags = tags ?? []; + tags = tags?.concat(imageTag); + versionToImageTags.set(version, tags); + maxVersionNumber = version && version > maxVersionNumber ? version : maxVersionNumber; + }); + + // Find the version maps to the target framework and default to max version in the tags + const targetVersion = utils.findSqlVersionInTargetPlatform(constants.getTargetPlatformFromVersion(target)) || maxVersionNumber; + + // Get the image tags with no version of the one that matches project platform + versionToImageTags.forEach((tags: string[], version: number) => { + if (version === defaultVersion || version >= targetVersion) { + imageTags = imageTags?.concat(tags); + } + }); + + imageTags = imageTags ?? []; + imageTags = imageTags.sort((a, b) => a.indexOf(constants.dockerImageDefaultTag) > 0 ? -1 : a.localeCompare(b)); + } + } catch (err) { + // Ignore the error. If http request fails, we just use the default tag + console.debug(`Failed to get docker image tags ${err}`); + } + + return imageTags; +} + +/** + * Returns the list of base images for given target version + * @param target + * @returns list of image info + */ export function getDockerBaseImages(target: string): DockerImageInfo[] { if (target === constants.targetPlatformToVersion.get(SqlTargetPlatform.sqlAzure)) { return [{ - name: `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}:2019-latest`, - displayName: SqlTargetPlatform.sqlServer2019, + name: `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}`, + displayName: constants.AzureSqlDbFullDockerImageName, agreementInfo: { link: { text: constants.eulaAgreementTitle, url: constants.sqlServerEulaLink, } - } + }, + tagsUrl: `https://${constants.sqlServerDockerRegistry}/v2/${constants.sqlServerDockerRepository}/tags/list`, + defaultTag: constants.dockerImageDefaultTag }, { - name: `${constants.sqlServerDockerRegistry}/${constants.azureSqlEdgeDockerRepository}:latest`, - displayName: SqlTargetPlatform.sqlEdge, + name: `${constants.sqlServerDockerRegistry}/${constants.azureSqlEdgeDockerRepository}`, + displayName: constants.AzureSqlDbLiteDockerImageName, agreementInfo: { link: { text: constants.edgeEulaAgreementTitle, url: constants.sqlServerEdgeEulaLink, } - } + }, + tagsUrl: `https://${constants.sqlServerDockerRegistry}/v2/${constants.azureSqlEdgeDockerRepository}/tags/list`, + defaultTag: constants.dockerImageDefaultTag }]; } else { return [ { - name: `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}:2017-latest`, - displayName: SqlTargetPlatform.sqlServer2017, + name: `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}`, + displayName: constants.SqlServerDockerImageName, agreementInfo: { link: { text: constants.eulaAgreementTitle, url: constants.sqlServerEulaLink, } - } + }, + tagsUrl: `https://${constants.sqlServerDockerRegistry}/v2/${constants.sqlServerDockerRepository}/tags/list`, + defaultTag: constants.dockerImageDefaultTag }, { - name: `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}:2019-latest`, - displayName: SqlTargetPlatform.sqlServer2019, - agreementInfo: { - link: { - text: constants.eulaAgreementTitle, - url: constants.sqlServerEulaLink, - } - } - }, - { - name: `${constants.sqlServerDockerRegistry}/${constants.azureSqlEdgeDockerRepository}:latest`, + name: `${constants.sqlServerDockerRegistry}/${constants.azureSqlEdgeDockerRepository}`, displayName: SqlTargetPlatform.sqlEdge, agreementInfo: { link: { text: constants.edgeEulaAgreementTitle, url: constants.sqlServerEdgeEulaLink, } - } + }, + tagsUrl: `https://${constants.sqlServerDockerRegistry}/v2/${constants.azureSqlEdgeDockerRepository}/tags/list`, + defaultTag: constants.dockerImageDefaultTag }, ]; } diff --git a/extensions/sql-database-projects/src/models/deploy/deployProfile.ts b/extensions/sql-database-projects/src/models/deploy/deployProfile.ts index 00e858033b..70511603c5 100644 --- a/extensions/sql-database-projects/src/models/deploy/deployProfile.ts +++ b/extensions/sql-database-projects/src/models/deploy/deployProfile.ts @@ -53,7 +53,9 @@ export interface ISqlConnectionProperties { export interface DockerImageInfo { name: string, displayName: string, - agreementInfo: AgreementInfo + agreementInfo: AgreementInfo, + tagsUrl: string, + defaultTag: string } export interface AgreementInfo { link: azdataType.LinkArea; diff --git a/extensions/sql-database-projects/src/test/utils.test.ts b/extensions/sql-database-projects/src/test/utils.test.ts index bbf12d383c..e45b4ea5ac 100644 --- a/extensions/sql-database-projects/src/test/utils.test.ts +++ b/extensions/sql-database-projects/src/test/utils.test.ts @@ -7,7 +7,7 @@ import * as should from 'should'; import * as path from 'path'; import * as os from 'os'; import { createDummyFileStructure } from './testUtils'; -import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName, timeConversion, validateSqlServerPortNumber, isEmptyString, detectCommandInstallation, isValidSQLPassword } from '../common/utils'; +import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName, timeConversion, validateSqlServerPortNumber, isEmptyString, detectCommandInstallation, isValidSQLPassword, findSqlVersionInImageName, findSqlVersionInTargetPlatform } from '../common/utils'; import { Uri } from 'vscode'; describe('Tests to verify utils functions', function (): void { @@ -123,5 +123,20 @@ describe('Tests to verify utils functions', function (): void { should(isValidSQLPassword('dFdf65$530')).equals(true, 'dF65$530 is valid password'); should(isValidSQLPassword('av1fgh533@')).equals(true, 'dF65$530 is valid password'); }); + + it('findSqlVersionInImageName should return the version correctly', () => { + should(findSqlVersionInImageName('2017-CU1-ubuntu')).equals(2017, 'invalid number returned for 2017-CU1-ubuntu'); + should(findSqlVersionInImageName('2019-latest')).equals(2019, 'invalid number returned for 2019-latest'); + should(findSqlVersionInImageName('latest')).equals(undefined, 'invalid number returned for latest'); + should(findSqlVersionInImageName('latest-ubuntu')).equals(undefined, 'invalid number returned for latest-ubuntu'); + should(findSqlVersionInImageName('2017-CU20-ubuntu-16.04')).equals(2017, 'invalid number returned for 2017-CU20-ubuntu-16.04'); + }); + + it('findSqlVersionInTargetPlatform should return the version correctly', () => { + should(findSqlVersionInTargetPlatform('SQL Server 2012')).equals(2012, 'invalid number returned for SQL Server 2012'); + should(findSqlVersionInTargetPlatform('SQL Server 2019')).equals(2019, 'invalid number returned for SQL Server 2019'); + should(findSqlVersionInTargetPlatform('Azure SQL Database')).equals(undefined, 'invalid number returned for Azure SQL Database'); + should(findSqlVersionInTargetPlatform('Azure Synapse Dedicated SQL Pool')).equals(undefined, 'invalid number returned for Azure Synapse Dedicated SQL Pool'); + }); }); diff --git a/extensions/sql-database-projects/yarn.lock b/extensions/sql-database-projects/yarn.lock index 86b73dfe7e..b0c06b6a62 100644 --- a/extensions/sql-database-projects/yarn.lock +++ b/extensions/sql-database-projects/yarn.lock @@ -381,6 +381,19 @@ async-listener@^0.6.0: semver "^5.3.0" shimmer "^1.1.0" +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -467,6 +480,13 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + commander@2.15.1: version "2.15.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" @@ -532,6 +552,11 @@ default-require-extensions@^3.0.0: dependencies: strip-bom "^4.0.0" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + diagnostic-channel-publishers@^0.3.3: version "0.3.5" resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.3.5.tgz#a84a05fd6cc1d7619fdd17791c17e540119a7536" @@ -601,6 +626,20 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +follow-redirects@^1.14.9: + version "1.15.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.0.tgz#06441868281c86d0dda4ad8bdaead2d02dca89d4" + integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + fs-extra@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd" @@ -869,6 +908,18 @@ micromatch@^4.0.4: braces "^3.0.1" picomatch "^2.2.3" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + minimatch@3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"