mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-22 09:35:37 -05:00
SQL Project Deploy to docker container - Adding a UI for user to select docker image tag (#19297)
This commit is contained in:
@@ -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}.");
|
||||
|
||||
51
extensions/sql-database-projects/src/common/httpClient.ts
Normal file
51
extensions/sql-database-projects/src/common/httpClient.ts
Normal file
@@ -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<string, any> = 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<any> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ILocalDbDeployProfile | undefined> {
|
||||
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,
|
||||
|
||||
@@ -213,9 +213,13 @@ export async function launchPublishTargetOption(project: Project): Promise<const
|
||||
const target = project.getProjectTargetVersion();
|
||||
const name = getPublishServerName(target);
|
||||
const logicalServerName = target === constants.targetPlatformToVersion.get(SqlTargetPlatform.sqlAzure) ? constants.AzureSqlLogicalServerName : constants.SqlServerName;
|
||||
|
||||
// Options list based on target
|
||||
const options = target === constants.targetPlatformToVersion.get(SqlTargetPlatform.sqlAzure) ?
|
||||
[constants.publishToDockerContainer(name), constants.publishToNewAzureServer, constants.publishToExistingServer(logicalServerName)] :
|
||||
[constants.publishToAzureEmulator, constants.publishToNewAzureServer, constants.publishToExistingServer(logicalServerName)] :
|
||||
[constants.publishToDockerContainer(name), constants.publishToExistingServer(logicalServerName)];
|
||||
|
||||
// Show the options to the user
|
||||
const publishOption = await vscode.window.showQuickPick(
|
||||
options,
|
||||
{ title: constants.selectPublishOption, ignoreFocusOut: true });
|
||||
@@ -225,11 +229,14 @@ export async function launchPublishTargetOption(project: Project): Promise<const
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Map the title to the publish option type
|
||||
switch (publishOption) {
|
||||
case constants.publishToExistingServer(name):
|
||||
return constants.PublishTargetType.existingServer;
|
||||
case constants.publishToDockerContainer(name):
|
||||
return constants.PublishTargetType.docker;
|
||||
case constants.publishToAzureEmulator:
|
||||
return constants.PublishTargetType.docker;
|
||||
case constants.publishToNewAzureServer:
|
||||
return constants.PublishTargetType.newAzureServer;
|
||||
default:
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { SqlTargetPlatform } from 'sqldbproj';
|
||||
import * as constants from '../common/constants';
|
||||
import * as utils from '../common/utils';
|
||||
import { HttpClient } from '../common/httpClient';
|
||||
import { AgreementInfo, DockerImageInfo } from '../models/deploy/deployProfile';
|
||||
|
||||
/**
|
||||
@@ -38,58 +40,117 @@ export function getPublishServerName(target: string): string {
|
||||
return target === constants.targetPlatformToVersion.get(SqlTargetPlatform.sqlAzure) ? constants.AzureSqlServerName : constants.SqlServerName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the docker image place holder based on the target version
|
||||
*/
|
||||
export function getDockerImagePlaceHolder(target: string): string {
|
||||
return target === constants.targetPlatformToVersion.get(SqlTargetPlatform.sqlAzure) ?
|
||||
constants.dockerImagesPlaceHolder(constants.AzureSqlDbLiteDockerImageName) :
|
||||
constants.dockerImagesPlaceHolder(SqlTargetPlatform.sqlEdge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of image tags for given target
|
||||
* @param imageInfo docker image info
|
||||
* @param target project target version
|
||||
* @returns image tags
|
||||
*/
|
||||
export async function getImageTags(imageInfo: DockerImageInfo, target: string): Promise<string[]> {
|
||||
let imageTags: string[] | undefined = [];
|
||||
const versionToImageTags: Map<number, string[]> = new Map<number, string[]>();
|
||||
|
||||
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
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user