SQL Project - deploy to docker publish option (#17050)

SQL Project - deploy to docker publish option
This commit is contained in:
Leila Lali
2021-09-13 14:12:53 -07:00
committed by GitHub
parent 90bb9c3c55
commit 4912faa966
13 changed files with 158 additions and 143 deletions

View File

@@ -128,13 +128,13 @@ export const done = localize('done', "Done");
export const nameMustNotBeEmpty = localize('nameMustNotBeEmpty', "Name must not be empty");
// Deploy
export const selectDeployOption = localize('selectDeployOption', "Select where to deploy the project to");
export const deployToExistingServer = localize('deployToExistingServer', "Deploy to existing server");
export const deployToDockerContainer = localize('deployToDockerContainer', "Deploy to docker container");
export const selectPublishOption = localize('selectPublishOption', "Select where to publish the project to");
export const publishToExistingServer = localize('publishToExistingServer', "Publish to existing server");
export const publishToDockerContainer = localize('publishToDockerContainer', "Publish to docker container");
export const enterPortNumber = localize('enterPortNumber', "Enter port number or press enter to use the default value");
export const enterConnectionStringEnvName = localize('enterConnectionStringEnvName', "Enter connection string environment variable name");
export const enterConnectionStringTemplate = localize('enterConnectionStringTemplate', "Enter connection string template");
export const enterPassword = localize('enterPassword', "Enter password or press enter to use the generated password");
export const enterPassword = localize('enterPassword', "Enter password");
export const portMustBeNumber = localize('portMustNotBeNumber', "Port must a be number");
export const valueCannotBeEmpty = localize('valueCannotBeEmpty', "Value cannot be empty");
export const dockerImageLabelPrefix = 'source=sqldbproject';

View File

@@ -505,3 +505,15 @@ export async function getAllProjectsInFolder(folder: vscode.Uri, projectExtensio
// glob will return an array of file paths with forward slashes, so they need to be converted back if on windows
return (await glob(projFilter)).map(p => vscode.Uri.file(path.resolve(p)));
}
export function validateSqlServerPortNumber(port: string | undefined): boolean {
if (!port) {
return false;
}
const valueAsNum = +port;
return !isNaN(valueAsNum) && valueAsNum > 0 && valueAsNum < 65535;
}
export function isEmptyString(password: string | undefined): boolean {
return password === undefined || password === '';
}

View File

@@ -55,7 +55,6 @@ export default class MainController implements vscode.Disposable {
vscode.commands.registerCommand('sqlDatabaseProjects.build', async (node: WorkspaceTreeItem) => { return this.projectsController.buildProject(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.publish', async (node: WorkspaceTreeItem) => { this.projectsController.publishProject(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.deployLocal', async (node: WorkspaceTreeItem) => { return this.projectsController.deployProject(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.schemaCompare', async (node: WorkspaceTreeItem) => { return this.projectsController.schemaCompare(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.createProjectFromDatabase', async (context: azdataType.IConnectionProfile | vscodeMssql.ITreeNodeInfo | undefined) => { return this.projectsController.createProjectFromDatabase(context); });

View File

@@ -35,7 +35,7 @@ import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/t
import { IconPathHelper } from '../common/iconHelper';
import { DashboardData, PublishData, Status } from '../models/dashboardData/dashboardData';
import { launchPublishDatabaseQuickpick } from '../dialogs/publishDatabaseQuickpick';
import { launchDeployDatabaseQuickpick } from '../dialogs/deployDatabaseQuickpick';
import { launchPublishToDockerContainerQuickpick } from '../dialogs/deployDatabaseQuickpick';
import { DeployService } from '../models/deploy/deployService';
import { SqlTargetPlatform } from 'sqldbproj';
import { createNewProjectFromDatabaseWithQuickpick } from '../dialogs/createProjectFromDatabaseQuickpick';
@@ -256,13 +256,13 @@ export class ProjectsController {
}
/**
* Deploys a project
* Publishes a project to docker container
* @param treeNode a treeItem in a project's hierarchy, to be used to obtain a Project
*/
public async deployProject(context: Project | dataworkspace.WorkspaceTreeItem): Promise<void> {
public async publishToDockerContainer(context: Project | dataworkspace.WorkspaceTreeItem): Promise<void> {
const project: Project = this.getProjectFromContext(context);
try {
let deployProfile = await launchDeployDatabaseQuickpick(project);
let deployProfile = await launchPublishToDockerContainerQuickpick(project);
if (deployProfile && deployProfile.deploySettings) {
let connectionUri: string | undefined;
if (deployProfile.localDbSetting) {
@@ -274,10 +274,6 @@ export class ProjectsController {
if (deployProfile.deploySettings.connectionUri) {
const publishResult = await this.publishOrScriptProject(project, deployProfile.deploySettings, true);
if (publishResult && publishResult.success) {
// Update app settings if requested by user
//
await this.deployService.updateAppSettings(deployProfile);
if (deployProfile.localDbSetting) {
await this.deployService.getConnection(deployProfile.localDbSetting, true, deployProfile.localDbSetting.dbName);
}

View File

@@ -5,9 +5,9 @@
import * as vscode from 'vscode';
import * as constants from '../common/constants';
import { AppSettingType, IDeployProfile, ILocalDbSetting } from '../models/deploy/deployProfile';
import * as utils from '../common/utils';
import { AppSettingType, IDeployAppIntegrationProfile, IDeployProfile, ILocalDbSetting } from '../models/deploy/deployProfile';
import { Project } from '../models/project';
import * as generator from 'generate-password';
import { getPublishDatabaseSettings } from './publishDatabaseQuickpick';
import * as path from 'path';
import * as fse from 'fs-extra';
@@ -15,73 +15,7 @@ import * as fse from 'fs-extra';
/**
* Create flow for Deploying a database using only VS Code-native APIs such as QuickPick
*/
export async function launchDeployDatabaseQuickpick(project: Project): Promise<IDeployProfile | undefined> {
// Show options to user for deploy to existing server or docker
const deployOption = await vscode.window.showQuickPick(
[constants.deployToExistingServer, constants.deployToDockerContainer],
{ title: constants.selectDeployOption, ignoreFocusOut: true });
// Return when user hits escape
if (!deployOption) {
return undefined;
}
let localDbSetting: ILocalDbSetting | undefined;
// Deploy to docker selected
if (deployOption === constants.deployToDockerContainer) {
let portNumber = await vscode.window.showInputBox({
title: constants.enterPortNumber,
ignoreFocusOut: true,
value: constants.defaultPortNumber,
validateInput: input => isNaN(+input) ? constants.portMustBeNumber : undefined
}
);
// Return when user hits escape
if (!portNumber) {
return undefined;
}
let password: string | undefined = generator.generate({
length: 10,
numbers: true,
symbols: true,
lowercase: true,
uppercase: true,
exclude: '`"\'' // Exclude the chars that cannot be included in the password. Some chars can make the command fail in the terminal
});
password = await vscode.window.showInputBox({
title: constants.enterPassword,
ignoreFocusOut: true,
value: password,
password: true
}
);
// Return when user hits escape
if (!password) {
return undefined;
}
localDbSetting = {
serverName: 'localhost',
userName: 'sa',
dbName: project.projectFileName,
password: password,
port: +portNumber,
};
}
let deploySettings = await getPublishDatabaseSettings(project, deployOption !== constants.deployToDockerContainer);
// Return when user hits escape
if (!deploySettings) {
return undefined;
}
// TODO: Ask for SQL CMD Variables or profile
export async function launchDeployAppIntegrationQuickpick(project: Project): Promise<IDeployAppIntegrationProfile | undefined> {
let envVarName: string | undefined = '';
const integrateWithAzureFunctions: boolean = true; //TODO: get value from settings or quickpick
@@ -116,7 +50,7 @@ export async function launchDeployDatabaseQuickpick(project: Project): Promise<I
title: constants.enterConnectionStringEnvName,
ignoreFocusOut: true,
value: constants.defaultConnectionStringEnvVarName,
validateInput: input => input === '' ? constants.valueCannotBeEmpty : undefined,
validateInput: input => utils.isEmptyString(input) ? constants.valueCannotBeEmpty : undefined,
placeHolder: constants.enterConnectionStringEnvNameDescription
}
);
@@ -128,15 +62,70 @@ export async function launchDeployDatabaseQuickpick(project: Project): Promise<I
}
}
return {
envVariableName: envVarName,
appSettingFile: settingExist ? localSettings : undefined,
appSettingType: settingExist ? AppSettingType.AzureFunction : AppSettingType.None
};
}
/**
* 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<IDeployProfile | undefined> {
let localDbSetting: ILocalDbSetting | undefined;
// Deploy to docker selected
let portNumber = await vscode.window.showInputBox({
title: constants.enterPortNumber,
ignoreFocusOut: true,
value: constants.defaultPortNumber,
validateInput: input => !utils.validateSqlServerPortNumber(input) ? constants.portMustBeNumber : undefined
}
);
// Return when user hits escape
if (!portNumber) {
return undefined;
}
let password: string | undefined = '';
password = await vscode.window.showInputBox({
title: constants.enterPassword,
ignoreFocusOut: true,
value: password,
validateInput: input => utils.isEmptyString(input) ? constants.valueCannotBeEmpty : undefined,
password: true
}
);
// Return when user hits escape
if (!password) {
return undefined;
}
localDbSetting = {
serverName: 'localhost',
userName: 'sa',
dbName: project.projectFileName,
password: password,
port: +portNumber,
};
let deploySettings = await getPublishDatabaseSettings(project, false);
// Return when user hits escape
if (!deploySettings) {
return undefined;
}
if (localDbSetting && deploySettings) {
deploySettings.serverName = localDbSetting.serverName;
}
return {
localDbSetting: localDbSetting,
envVariableName: envVarName,
appSettingFile: settingExist ? localSettings : undefined,
deploySettings: deploySettings,
appSettingType: settingExist ? AppSettingType.AzureFunction : AppSettingType.None
};
}

View File

@@ -207,17 +207,37 @@ export async function getPublishDatabaseSettings(project: Project, promptForConn
* Create flow for Publishing a database using only VS Code-native APIs such as QuickPick
*/
export async function launchPublishDatabaseQuickpick(project: Project, projectController: ProjectsController): Promise<void> {
let settings: IDeploySettings | undefined = await getPublishDatabaseSettings(project);
const publishTarget = await launchPublishTargetOption();
if (publishTarget === constants.publishToDockerContainer) {
await projectController.publishToDockerContainer(project);
} else {
let settings: IDeploySettings | undefined = await getPublishDatabaseSettings(project);
if (settings) {
// 5. Select action to take
const action = await vscode.window.showQuickPick(
[constants.generateScriptButtonText, constants.publish],
{ title: constants.chooseAction, ignoreFocusOut: true });
if (!action) {
return;
if (settings) {
// 5. Select action to take
const action = await vscode.window.showQuickPick(
[constants.generateScriptButtonText, constants.publish],
{ title: constants.chooseAction, ignoreFocusOut: true });
if (!action) {
return;
}
await projectController.publishOrScriptProject(project, settings, action === constants.publish);
}
await projectController.publishOrScriptProject(project, settings, action === constants.publish);
}
}
async function launchPublishTargetOption(): Promise<string | undefined> {
// Show options to user for deploy to existing server or docker
const publishOption = await vscode.window.showQuickPick(
[constants.publishToExistingServer, constants.publishToDockerContainer],
{ title: constants.selectPublishOption, ignoreFocusOut: true });
// Return when user hits escape
if (!publishOption) {
return undefined;
}
return publishOption;
}

View File

@@ -7,6 +7,9 @@ export enum AppSettingType {
export interface IDeployProfile {
localDbSetting?: ILocalDbSetting;
deploySettings?: IDeploySettings;
}
export interface IDeployAppIntegrationProfile {
envVariableName?: string;
appSettingFile?: string;
appSettingType: AppSettingType;

View File

@@ -3,7 +3,7 @@
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { AppSettingType, IDeployProfile, ILocalDbSetting } from './deployProfile';
import { AppSettingType, IDeployAppIntegrationProfile, IDeployProfile, ILocalDbSetting } from './deployProfile';
import * as UUID from 'vscode-languageclient/lib/utils/uuid';
import { Project } from '../project';
import * as constants from '../../common/constants';
@@ -32,7 +32,7 @@ export class DeployService {
return '';
}
private findAppRuntime(profile: IDeployProfile, appSettingContent: any): string | undefined {
private findAppRuntime(profile: IDeployAppIntegrationProfile, appSettingContent: any): string | undefined {
switch (profile.appSettingType) {
case AppSettingType.AzureFunction:
return <string>appSettingContent?.Values['FUNCTIONS_WORKER_RUNTIME'];
@@ -41,7 +41,7 @@ export class DeployService {
return undefined;
}
public async updateAppSettings(profile: IDeployProfile): Promise<void> {
public async updateAppSettings(profile: IDeployAppIntegrationProfile, deployProfile: IDeployProfile | undefined): Promise<void> {
// Update app settings
//
if (!profile.appSettingFile) {
@@ -53,22 +53,22 @@ export class DeployService {
let content = JSON.parse(fse.readFileSync(profile.appSettingFile, 'utf8'));
if (content && content.Values) {
let connectionString: string | undefined = '';
if (profile.localDbSetting) {
if (deployProfile && deployProfile.localDbSetting) {
// Find the runtime and generate the connection string for the runtime
//
const runtime = this.findAppRuntime(profile, content);
let connectionStringTemplate = this.createConnectionStringTemplate(runtime);
const macroDict: Record<string, string> = {
'SERVER': profile?.localDbSetting?.serverName || '',
'PORT': profile?.localDbSetting?.port?.toString() || '',
'USER': profile?.localDbSetting?.userName || '',
'SA_PASSWORD': profile?.localDbSetting?.password || '',
'DATABASE': profile?.localDbSetting?.dbName || '',
'SERVER': deployProfile?.localDbSetting?.serverName || '',
'PORT': deployProfile?.localDbSetting?.port?.toString() || '',
'USER': deployProfile?.localDbSetting?.userName || '',
'SA_PASSWORD': deployProfile?.localDbSetting?.password || '',
'DATABASE': deployProfile?.localDbSetting?.dbName || '',
};
connectionString = templates.macroExpansion(connectionStringTemplate, macroDict);
} else if (profile.deploySettings?.connectionUri) {
connectionString = await this.getConnectionString(profile.deploySettings?.connectionUri);
} else if (deployProfile?.deploySettings?.connectionUri) {
connectionString = await this.getConnectionString(deployProfile?.deploySettings?.connectionUri);
}
if (connectionString && profile.envVariableName) {
@@ -88,8 +88,8 @@ export class DeployService {
return undefined;
}
const projectName = project.projectFileName;
const imageLabel = `${constants.dockerImageLabelPrefix}_${projectName}`;
const imageName = `${constants.dockerImageNamePrefix}-${projectName}-${UUID.generateUuid().toLowerCase()}`;
const imageLabel = `${constants.dockerImageLabelPrefix}_${projectName}`.toLocaleLowerCase();
const imageName = `${constants.dockerImageNamePrefix}-${projectName}-${UUID.generateUuid()}`.toLocaleLowerCase();
const root = project.projectFolderPath;
const mssqlFolderPath = path.join(root, constants.mssqlFolderName);
const commandsFolderPath = path.join(mssqlFolderPath, constants.commandsFolderName);

View File

@@ -66,10 +66,6 @@ describe('deploy service', function (): void {
it('Should deploy a database to docker container successfully', async function (): Promise<void> {
const testContext = createContext();
const deployProfile: IDeployProfile = {
appSettingType: AppSettingType.AzureFunction,
appSettingFile: '',
deploySettings: undefined,
envVariableName: '',
localDbSetting: {
dbName: 'test',
password: 'PLACEHOLDER',
@@ -137,10 +133,6 @@ describe('deploy service', function (): void {
await fse.writeFile(filePath, settingContent);
const deployProfile: IDeployProfile = {
appSettingType: AppSettingType.AzureFunction,
appSettingFile: filePath,
deploySettings: undefined,
envVariableName: 'SQLConnectionString',
localDbSetting: {
dbName: 'test',
password: 'PLACEHOLDER',
@@ -150,9 +142,14 @@ describe('deploy service', function (): void {
}
};
const appInteg = {appSettingType: AppSettingType.AzureFunction,
appSettingFile: filePath,
deploySettings: undefined,
envVariableName: 'SQLConnectionString'};
const deployService = new DeployService(testContext.outputChannel);
sandbox.stub(childProcess, 'exec').yields(undefined, 'id');
await deployService.updateAppSettings(deployProfile);
await deployService.updateAppSettings(appInteg, deployProfile);
let newContent = JSON.parse(fse.readFileSync(filePath, 'utf8'));
should(newContent).deepEqual(expected);
@@ -184,23 +181,26 @@ describe('deploy service', function (): void {
await fse.writeFile(filePath, settingContent);
const deployProfile: IDeployProfile = {
appSettingType: AppSettingType.AzureFunction,
appSettingFile: filePath,
deploySettings: {
connectionUri: 'connection',
databaseName: 'test',
serverName: 'test'
},
envVariableName: 'SQLConnectionString',
localDbSetting: undefined
};
const appInteg = {
appSettingType: AppSettingType.AzureFunction,
appSettingFile: filePath,
envVariableName: 'SQLConnectionString',
}
const deployService = new DeployService(testContext.outputChannel);
let connection = new azdata.connection.ConnectionProfile();
sandbox.stub(azdata.connection, 'getConnection').returns(Promise.resolve(connection));
sandbox.stub(childProcess, 'exec').yields(undefined, 'id');
sandbox.stub(azdata.connection, 'getConnectionString').returns(Promise.resolve('connectionString'));
await deployService.updateAppSettings(deployProfile);
await deployService.updateAppSettings(appInteg, deployProfile);
let newContent = JSON.parse(fse.readFileSync(filePath, 'utf8'));
should(newContent).deepEqual(expected);

View File

@@ -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 } from '../common/utils';
import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName, timeConversion, validateSqlServerPortNumber, isEmptyString } from '../common/utils';
import { Uri } from 'vscode';
describe('Tests to verify utils functions', function (): void {
@@ -88,5 +88,22 @@ describe('Tests to verify utils functions', function (): void {
should(timeConversion( (59 * 1000))).equal('59 sec');
should(timeConversion( (59))).equal('59 msec');
});
it('Should validate port number correctly', () => {
should(validateSqlServerPortNumber('invalid')).equals(false);
should(validateSqlServerPortNumber('')).equals(false);
should(validateSqlServerPortNumber(undefined)).equals(false);
should(validateSqlServerPortNumber('65536')).equals(false);
should(validateSqlServerPortNumber('-1')).equals(false);
should(validateSqlServerPortNumber('65530')).equals(true);
should(validateSqlServerPortNumber('1533')).equals(true);
});
it('Should validate empty string correctly', () => {
should(isEmptyString('invalid')).equals(false);
should(isEmptyString('')).equals(true);
should(isEmptyString(undefined)).equals(true);
should(isEmptyString('65536')).equals(false);
});
});