diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index dc01214cef..99cd4a4424 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -131,18 +131,23 @@ export const nameMustNotBeEmpty = localize('nameMustNotBeEmpty', "Name must not // Deploy 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 publishToDockerContainer = localize('publishToDockerContainer', "Publish to new server in a container"); +export const enterPortNumber = localize('enterPortNumber', "Enter SQL server 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"); -export const enterBaseImage = localize('enterBaseImage', "Enter the base SQL Server docker image or press enter to use the default value"); +export const enterPassword = localize('enterPassword', "Enter SQL Server admin password"); +export const confirmPassword = localize('confirmPassword', "Confirm SQL server admin password"); +export const selectBaseImage = localize('selectBaseImage', "Select the base SQL Server docker image"); +export const invalidSQLPasswordMessage = localize('invalidSQLPassword', "SQL Server password doesn't meet the password complexity requirement. For more information see https://docs.microsoft.com/sql/relational-databases/security/password-policy"); +export const passwordNotMatch = localize('passwordNotMatch', "SQL Server password doesn't match the confirmation 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'; export const dockerImageNamePrefix = 'sqldbproject'; export const connectionNamePrefix = 'SQLDbProject'; -export const defaultDockerBaseImage = 'mcr.microsoft.com/mssql/server:2019-latest'; +export const sqlServerDockerRegistry = 'mcr.microsoft.com'; +export const sqlServerDockerRepository = 'mssql/server'; +export const azureSqlEdgeDockerRepository = 'azure-sql-edge'; export const commandsFolderName = 'commands'; export const mssqlFolderName = '.mssql'; export const dockerFileName = 'Dockerfile'; @@ -159,12 +164,13 @@ export const deployProjectSucceed = localize('deployProjectSucceed', "Database p export const cleaningDockerImagesMessage = localize('cleaningDockerImagesMessage', "Cleaning existing deployments..."); export const creatingDeploymentSettingsMessage = localize('creatingDeploymentSettingsMessage', "Creating deployment settings ..."); export const runningDockerMessage = localize('runningDockerMessage', "Building and running the docker container ..."); +export function dockerNotRunningError(error: string) { return localize('dockerNotRunningError', "Failed to verify docker. Please make sure docker is installed and running. Error: '{0}'", error || ''); } export const dockerContainerNotRunningErrorMessage = localize('dockerContainerNotRunningErrorMessage', "Docker container is not running"); export const dockerContainerFailedToRunErrorMessage = localize('dockerContainerFailedToRunErrorMessage', "Failed to run the docker container"); export const connectingToSqlServerOnDockerMessage = localize('connectingToSqlServerOnDockerMessage', "Connecting to SQL Server on Docker"); export const deployProjectFailedMessage = localize('deployProjectFailedMessage', "Failed to open a connection to the deployed database'"); export function taskFailedError(taskName: string, err: string): string { return localize('taskFailedError.error', "Failed to complete task '{0}'. Error: {1}", taskName, err); } -export function deployProjectFailed(errorMessage: string) { return localize('deployProjectFailed', "Failed to deploy project. Check output pane for more details. {0}", errorMessage); } +export function publishToContainerFailed(errorMessage: string) { return localize('publishToContainerFailed', "Failed to publish to container. Check output pane for more details. {0}", errorMessage); } export function deployAppSettingUpdateFailed(appSetting: string) { return localize('deployAppSettingUpdateFailed', "Failed to update app setting '{0}'", appSetting); } export function deployAppSettingUpdating(appSetting: string) { return localize('deployAppSettingUpdating', "Updating app setting: '{0}'", appSetting); } export function connectionFailedError(error: string) { return localize('connectionFailedError', "Connection failed error: '{0}'", error); } diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index 8ab125bc0b..cf11358727 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -422,10 +422,16 @@ export async function createFolderIfNotExist(folderPath: string): Promise } } -export async function executeCommand(cmd: string, outputChannel: vscode.OutputChannel, timeout: number = 5 * 60 * 1000): Promise { +export async function executeCommand(cmd: string, outputChannel: vscode.OutputChannel, sensitiveData: string[] = [], timeout: number = 5 * 60 * 1000): Promise { return new Promise((resolve, reject) => { if (outputChannel) { - outputChannel.appendLine(` > ${cmd}`); + let cmdOutputMessage = cmd; + + sensitiveData.forEach(element => { + cmdOutputMessage = cmdOutputMessage.replace(element, '***'); + }); + + outputChannel.appendLine(` > ${cmdOutputMessage}`); } let child = childProcess.exec(cmd, { timeout: timeout @@ -535,3 +541,14 @@ export function validateSqlServerPortNumber(port: string | undefined): boolean { export function isEmptyString(password: string | undefined): boolean { return password === undefined || password === ''; } + +export function isValidSQLPassword(password: string, userName: string = 'sa'): boolean { + // Validate SQL Server password + const containsUserName = password && userName !== undefined && password.toUpperCase().includes(userName.toUpperCase()); + // Instead of using one RegEx, I am separating it to make it more readable. + const hasUpperCase = /[A-Z]/.test(password) ? 1 : 0; + const hasLowerCase = /[a-z]/.test(password) ? 1 : 0; + const hasNumbers = /\d/.test(password) ? 1 : 0; + const hasNonAlphas = /\W/.test(password) ? 1 : 0; + return !containsUserName && password.length >= 8 && password.length <= 128 && (hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphas >= 3); +} diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index b1ebc06c1d..11096442f0 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -283,14 +283,14 @@ export class ProjectsController { } void vscode.window.showInformationMessage(constants.deployProjectSucceed); } else { - void vscode.window.showErrorMessage(constants.deployProjectFailed(publishResult?.errorMessage || '')); + void vscode.window.showErrorMessage(constants.publishToContainerFailed(publishResult?.errorMessage || '')); } } else { - void vscode.window.showErrorMessage(constants.deployProjectFailed(constants.deployProjectFailedMessage)); + void vscode.window.showErrorMessage(constants.publishToContainerFailed(constants.deployProjectFailedMessage)); } } } catch (error) { - void vscode.window.showErrorMessage(constants.deployProjectFailed(utils.getErrorMessage(error))); + void vscode.window.showErrorMessage(constants.publishToContainerFailed(utils.getErrorMessage(error))); } return; } diff --git a/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts b/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts index 0e50748d7b..41293a36aa 100644 --- a/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts +++ b/extensions/sql-database-projects/src/dialogs/deployDatabaseQuickpick.ts @@ -94,7 +94,7 @@ export async function launchPublishToDockerContainerQuickpick(project: Project): title: constants.enterPassword, ignoreFocusOut: true, value: password, - validateInput: input => utils.isEmptyString(input) ? constants.valueCannotBeEmpty : undefined, + validateInput: input => !utils.isValidSQLPassword(input) ? constants.invalidSQLPasswordMessage : undefined, password: true } ); @@ -104,15 +104,29 @@ export async function launchPublishToDockerContainerQuickpick(project: Project): return undefined; } - let baseImage: string | undefined = ''; - baseImage = await vscode.window.showInputBox({ - title: constants.enterBaseImage, + let confirmPassword: string | undefined = ''; + confirmPassword = await vscode.window.showInputBox({ + title: constants.confirmPassword, ignoreFocusOut: true, - value: constants.defaultDockerBaseImage, - validateInput: input => utils.isEmptyString(input) ? constants.valueCannotBeEmpty : undefined + value: confirmPassword, + validateInput: input => input !== password ? constants.passwordNotMatch : undefined, + password: true } ); + // Return when user hits escape + if (!confirmPassword) { + return undefined; + } + + const baseImage = await vscode.window.showQuickPick( + [ + `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}:2017-latest`, + `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}:2019-latest`, + `${constants.sqlServerDockerRegistry}/${constants.azureSqlEdgeDockerRepository}:latest` + ], + { title: constants.selectBaseImage, ignoreFocusOut: true }); + // Return when user hits escape if (!baseImage) { return undefined; @@ -134,9 +148,11 @@ export async function launchPublishToDockerContainerQuickpick(project: Project): return undefined; } - if (localDbSetting && deploySettings) { - deploySettings.serverName = localDbSetting.serverName; - } + // Server name should be set to localhost + deploySettings.serverName = localDbSetting.serverName; + + // Get the database name from deploy settings + localDbSetting.dbName = deploySettings.databaseName; return { diff --git a/extensions/sql-database-projects/src/models/deploy/deployService.ts b/extensions/sql-database-projects/src/models/deploy/deployService.ts index 71fbf4bf88..47e4c2c0bb 100644 --- a/extensions/sql-database-projects/src/models/deploy/deployService.ts +++ b/extensions/sql-database-projects/src/models/deploy/deployService.ts @@ -85,11 +85,23 @@ export class DeployService { } } + private async verifyDocker(): Promise { + try { + await utils.executeCommand(`docker version --format {{.Server.APIVersion}}`, this._outputChannel); + // TODO verify min version + } catch (error) { + throw Error(constants.dockerNotRunningError(utils.getErrorMessage(error))); + } + } + public async deploy(profile: IDeployProfile, project: Project): Promise { return await this.executeTask(constants.deployDbTaskName, async () => { if (!profile.localDbSetting) { return undefined; } + + await this.verifyDocker(); + const projectName = project.projectFileName; const imageLabel = `${constants.dockerImageLabelPrefix}_${projectName}`.toLocaleLowerCase(); const imageName = `${constants.dockerImageNamePrefix}-${projectName}-${UUID.generateUuid()}`.toLocaleLowerCase(); @@ -146,13 +158,18 @@ export class DeployService { } private async buildAndRunDockerContainer(dockerFilePath: string, imageName: string, root: string, profile: ILocalDbSetting, imageLabel: string): Promise { + + // Sensitive data to remove from output console + const sensitiveData = [profile.password]; + + // Running commands to build the docker image this.logToOutput('Building docker image ...'); await utils.executeCommand(`docker pull ${profile.dockerBaseImage}`, this._outputChannel); await utils.executeCommand(`docker build -f ${dockerFilePath} -t ${imageName} ${root}`, this._outputChannel); await utils.executeCommand(`docker images --filter label=${imageLabel}`, this._outputChannel); this.logToOutput('Running docker container ...'); - await utils.executeCommand(`docker run -p ${profile.port}:1433 -e "MSSQL_SA_PASSWORD=${profile.password}" -d ${imageName}`, this._outputChannel); + await utils.executeCommand(`docker run -p ${profile.port}:1433 -e "MSSQL_SA_PASSWORD=${profile.password}" -d ${imageName}`, this._outputChannel, sensitiveData); return await utils.executeCommand(`docker ps -q -a --filter label=${imageLabel} -q`, this._outputChannel); } @@ -184,7 +201,7 @@ export class DeployService { providerName: 'MSSQL', saveProfile: false, id: '', - connectionName: `${constants.connectionNamePrefix} ${profile.dbName}`, + connectionName: `${constants.connectionNamePrefix} ${database}`, options: [], authenticationType: 'SqlLogin' }; diff --git a/extensions/sql-database-projects/src/test/deploy/deployService.test.ts b/extensions/sql-database-projects/src/test/deploy/deployService.test.ts index e20dae66b4..e4f78f14d4 100644 --- a/extensions/sql-database-projects/src/test/deploy/deployService.test.ts +++ b/extensions/sql-database-projects/src/test/deploy/deployService.test.ts @@ -88,6 +88,27 @@ describe('deploy service', function (): void { }); + it('Should deploy fails if docker is not running', async function (): Promise { + const testContext = createContext(); + const deployProfile: IDeployProfile = { + localDbSetting: { + dbName: 'test', + password: 'PLACEHOLDER', + port: 1433, + serverName: 'localhost', + userName: 'sa', + dockerBaseImage: 'image', + connectionRetryTimeout: 1 + } + }; + const projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const project1 = await Project.openProject(vscode.Uri.file(projFilePath).fsPath); + const deployService = new DeployService(testContext.outputChannel); + sandbox.stub(azdata.tasks, 'startBackgroundOperation').callThrough(); + sandbox.stub(childProcess, 'exec').throws('error'); + await should(deployService.deploy(deployProfile, project1)).rejected(); + }); + it('Should retry connecting to the server', async function (): Promise { const testContext = createContext(); const localDbSettings = { diff --git a/extensions/sql-database-projects/src/test/utils.test.ts b/extensions/sql-database-projects/src/test/utils.test.ts index 50cb653995..bbf12d383c 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 } from '../common/utils'; +import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName, timeConversion, validateSqlServerPortNumber, isEmptyString, detectCommandInstallation, isValidSQLPassword } from '../common/utils'; import { Uri } from 'vscode'; describe('Tests to verify utils functions', function (): void { @@ -110,5 +110,18 @@ describe('Tests to verify utils functions', function (): void { should(await detectCommandInstallation('node')).equal(true, '"node" should have been detected.'); should(await detectCommandInstallation('bogusFakeCommand')).equal(false, '"bogusFakeCommand" should have been detected.'); }); + + it('Should validate SQL server password correctly', () => { + should(isValidSQLPassword('invalid')).equals(false, 'string with chars only is invalid password'); + should(isValidSQLPassword('')).equals(false, 'empty string is invalid password'); + should(isValidSQLPassword('65536')).equals(false, 'string with numbers only is invalid password'); + should(isValidSQLPassword('dFGj')).equals(false, 'string with lowercase and uppercase char only is invalid password'); + should(isValidSQLPassword('dj$')).equals(false, 'string with char and symbols only is invalid password'); + should(isValidSQLPassword('dF65530')).equals(false, 'string with char and numbers only is invalid password'); + should(isValidSQLPassword('dF6$30')).equals(false, 'dF6$30 is invalid password'); + should(isValidSQLPassword('dF65$530')).equals(true, 'dF65$530 is valid password'); + should(isValidSQLPassword('dFdf65$530')).equals(true, 'dF65$530 is valid password'); + should(isValidSQLPassword('av1fgh533@')).equals(true, 'dF65$530 is valid password'); + }); });