SQL Database Project - Deploy db to docker (#16406)

Added a new command to deploy the project to docker
This commit is contained in:
Leila Lali
2021-08-12 13:24:16 -07:00
committed by GitHub
parent 57e11c7b5c
commit 32a71a2de6
18 changed files with 1111 additions and 92 deletions

View File

@@ -0,0 +1,361 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { AppSettingType, IDeployProfile, ILocalDbSetting } from './deployProfile';
import * as UUID from 'vscode-languageclient/lib/utils/uuid';
import { Project } from '../project';
import * as constants from '../../common/constants';
import * as utils from '../../common/utils';
import * as fse from 'fs-extra';
import * as path from 'path';
import * as vscode from 'vscode';
import * as os from 'os';
import { ConnectionResult } from 'azdata';
import * as templates from '../../templates/templates';
export class DeployService {
constructor(private _outputChannel: vscode.OutputChannel) {
}
private createConnectionStringTemplate(runtime: string | undefined): string {
switch (runtime?.toLocaleLowerCase()) {
case 'dotnet':
return constants.defaultConnectionStringTemplate;
break;
// TODO: add connection strings for other languages
default:
break;
}
return '';
}
private findAppRuntime(profile: IDeployProfile, appSettingContent: any): string | undefined {
switch (profile.appSettingType) {
case AppSettingType.AzureFunction:
return <string>appSettingContent?.Values['FUNCTIONS_WORKER_RUNTIME'];
default:
}
return undefined;
}
public async updateAppSettings(profile: IDeployProfile): Promise<void> {
// Update app settings
//
if (!profile.appSettingFile) {
return;
}
this.logToOutput(constants.deployAppSettingUpdating(profile.appSettingFile));
// TODO: handle parsing errors
let content = JSON.parse(fse.readFileSync(profile.appSettingFile, 'utf8'));
if (content && content.Values) {
let connectionString: string | undefined = '';
if (profile.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 || '',
};
connectionString = templates.macroExpansion(connectionStringTemplate, macroDict);
} else if (profile.deploySettings?.connectionUri) {
connectionString = await this.getConnectionString(profile.deploySettings?.connectionUri);
}
if (connectionString && profile.envVariableName) {
content.Values[profile.envVariableName] = connectionString;
await fse.writeFileSync(profile.appSettingFile, JSON.stringify(content, undefined, 4));
this.logToOutput(`app setting '${profile.appSettingFile}' has been updated. env variable name: ${profile.envVariableName} connection String: ${connectionString}`);
} else {
this.logToOutput(constants.deployAppSettingUpdateFailed(profile.appSettingFile));
}
}
}
public async deploy(profile: IDeployProfile, project: Project): Promise<string | undefined> {
return await this.executeTask(constants.deployDbTaskName, async () => {
if (!profile.localDbSetting) {
return undefined;
}
const projectName = project.projectFileName;
const imageLabel = `${constants.dockerImageLabelPrefix}_${projectName}`;
const imageName = `${constants.dockerImageNamePrefix}-${projectName}-${UUID.generateUuid().toLowerCase()}`;
const root = project.projectFolderPath;
const mssqlFolderPath = path.join(root, constants.mssqlFolderName);
const commandsFolderPath = path.join(mssqlFolderPath, constants.commandsFolderName);
const dockerFilePath = path.join(mssqlFolderPath, constants.dockerFileName);
const startFilePath = path.join(commandsFolderPath, constants.startCommandName);
this.logToOutput(constants.cleaningDockerImagesMessage);
// Clean up existing docker image
await this.cleanDockerObjects(`docker ps -q -a --filter label=${imageLabel}`, ['docker stop', 'docker rm']);
await this.cleanDockerObjects(`docker images -f label=${imageLabel} -q`, [`docker rmi -f `]);
this.logToOutput(constants.creatingDeploymentSettingsMessage);
// Create commands
//
await this.createCommands(mssqlFolderPath, commandsFolderPath, dockerFilePath, startFilePath, imageLabel);
this.logToOutput(constants.runningDockerMessage);
// Building the image and running the docker
//
const createdDockerId: string | undefined = await this.buildAndRunDockerContainer(dockerFilePath, imageName, root, profile.localDbSetting, imageLabel);
this.logToOutput(`Docker container created. Id: ${createdDockerId}`);
// Waiting a bit to make sure docker container doesn't crash
//
const runningDockerId = await utils.retry('Validating the docker container', async () => {
return await utils.executeCommand(`docker ps -q -a --filter label=${imageLabel} -q`, this._outputChannel);
}, (dockerId) => {
return Promise.resolve({ validated: dockerId !== undefined, errorMessage: constants.dockerContainerNotRunningErrorMessage });
}, (dockerId) => {
return Promise.resolve(dockerId);
}, this._outputChannel
);
if (runningDockerId) {
this.logToOutput(constants.dockerContainerCreatedMessage(runningDockerId));
return await this.getConnection(profile.localDbSetting, false, 'master');
} else {
this.logToOutput(constants.dockerContainerFailedToRunErrorMessage);
if (createdDockerId) {
// Get the docker logs if docker was created but crashed
await utils.executeCommand(constants.dockerLogMessage(createdDockerId), this._outputChannel);
}
}
return undefined;
});
}
private async buildAndRunDockerContainer(dockerFilePath: string, imageName: string, root: string, profile: ILocalDbSetting, imageLabel: string): Promise<string | undefined> {
this.logToOutput('Building docker image ...');
await utils.executeCommand(`docker pull ${constants.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);
return await utils.executeCommand(`docker ps -q -a --filter label=${imageLabel} -q`, this._outputChannel);
}
private async getConnectionString(connectionUri: string): Promise<string | undefined> {
const getAzdataApi = await utils.getAzdataApi();
if (getAzdataApi) {
const connection = await getAzdataApi.connection.getConnection(connectionUri);
if (connection) {
return await getAzdataApi.connection.getConnectionString(connection.connectionId, true);
}
}
// TODO: vscode connections string
return undefined;
}
// Connects to a database
private async connectToDatabase(profile: ILocalDbSetting, savePassword: boolean, database: string): Promise<ConnectionResult | string | undefined> {
const getAzdataApi = await utils.getAzdataApi();
const vscodeMssqlApi = getAzdataApi ? undefined : await utils.getVscodeMssqlApi();
if (getAzdataApi) {
const connectionProfile = {
password: profile.password,
serverName: `${profile.serverName},${profile.port}`,
database: database,
savePassword: savePassword,
userName: profile.userName,
providerName: 'MSSQL',
saveProfile: false,
id: '',
connectionName: `${constants.connectionNamePrefix} ${profile.dbName}`,
options: [],
authenticationType: 'SqlLogin'
};
return await getAzdataApi.connection.connect(connectionProfile, false, false);
} else if (vscodeMssqlApi) {
const connectionProfile = {
password: profile.password,
server: `${profile.serverName}`,
port: profile.port,
database: database,
savePassword: savePassword,
user: profile.userName,
authenticationType: 'SqlLogin',
encrypt: false,
connectTimeout: 30,
applicationName: 'SQL Database Project',
accountId: undefined,
azureAccountToken: undefined,
applicationIntent: undefined,
attachDbFilename: undefined,
connectRetryCount: undefined,
connectRetryInterval: undefined,
connectionString: undefined,
currentLanguage: undefined,
email: undefined,
failoverPartner: undefined,
loadBalanceTimeout: undefined,
maxPoolSize: undefined,
minPoolSize: undefined,
multiSubnetFailover: undefined,
multipleActiveResultSets: undefined,
packetSize: undefined,
persistSecurityInfo: undefined,
pooling: undefined,
replication: undefined,
trustServerCertificate: undefined,
typeSystemVersion: undefined,
workstationId: undefined
};
let connectionUrl = await vscodeMssqlApi.connect(connectionProfile);
return connectionUrl;
} else {
return undefined;
}
}
// Validates the connection result. If using azdata API, verifies connection was successful and connection id is returns
// If using vscode API, verifies the connection url is returns
private async validateConnection(connection: ConnectionResult | string | undefined): Promise<utils.ValidationResult> {
const getAzdataApi = await utils.getAzdataApi();
if (!connection) {
return { validated: false, errorMessage: constants.connectionFailedError('No result returned') };
} else if (getAzdataApi) {
const connectionResult = <ConnectionResult>connection;
if (connectionResult) {
const connected = connectionResult !== undefined && connectionResult.connected && connectionResult.connectionId !== undefined;
return { validated: connected, errorMessage: connected ? '' : constants.connectionFailedError(connectionResult?.errorMessage) };
} else {
return { validated: false, errorMessage: constants.connectionFailedError('') };
}
} else {
return { validated: connection !== undefined, errorMessage: constants.connectionFailedError('') };
}
}
// Formats connection result to string to be able to add to log
private async formatConnectionResult(connection: ConnectionResult | string | undefined): Promise<string> {
const getAzdataApi = await utils.getAzdataApi();
const connectionResult = connection !== undefined && getAzdataApi ? <ConnectionResult>connection : undefined;
return connectionResult ? connectionResult.connectionId : <string>connection;
}
public async getConnection(profile: ILocalDbSetting, savePassword: boolean, database: string, timeoutInSeconds: number = 5): Promise<string | undefined> {
const getAzdataApi = await utils.getAzdataApi();
let connection = await utils.retry(
constants.connectingToSqlServerOnDockerMessage,
async () => {
return await this.connectToDatabase(profile, savePassword, database);
},
this.validateConnection,
this.formatConnectionResult,
this._outputChannel,
5, timeoutInSeconds);
if (connection) {
const connectionResult = <ConnectionResult>connection;
if (getAzdataApi) {
return await getAzdataApi.connection.getUriForConnection(connectionResult.connectionId);
} else {
return <string>connection;
}
}
return undefined;
}
private async executeTask<T>(taskName: string, task: () => Promise<T>): Promise<T> {
const getAzdataApi = await utils.getAzdataApi();
if (getAzdataApi) {
return new Promise<T>((resolve, reject) => {
let msgTaskName = taskName;
getAzdataApi!.tasks.startBackgroundOperation({
displayName: msgTaskName,
description: msgTaskName,
isCancelable: false,
operation: async op => {
try {
let result: T = await task();
op.updateStatus(getAzdataApi!.TaskStatus.Succeeded);
resolve(result);
} catch (error) {
let errorMsg = constants.taskFailedError(taskName, error ? error.message : '');
op.updateStatus(getAzdataApi!.TaskStatus.Failed, errorMsg);
reject(errorMsg);
}
}
});
});
} else {
return await task();
}
}
private logToOutput(message: string): void {
this._outputChannel.appendLine(message);
}
// Creates command file and docker file needed for deploy operation
private async createCommands(mssqlFolderPath: string, commandsFolderPath: string, dockerFilePath: string, startFilePath: string, imageLabel: string): Promise<void> {
// Create mssql folders if doesn't exist
//
await utils.createFolderIfNotExist(mssqlFolderPath);
await utils.createFolderIfNotExist(commandsFolderPath);
// Start command
//
await this.createFile(startFilePath, 'echo starting the container!');
if (os.platform() !== 'win32') {
await utils.executeCommand(`chmod +x ${startFilePath}`, this._outputChannel);
}
// Create the Dockerfile
//
await this.createFile(dockerFilePath,
`
FROM ${constants.dockerBaseImage}
ENV ACCEPT_EULA=Y
ENV MSSQL_PID=Developer
LABEL ${imageLabel}
RUN mkdir -p /opt/sqlproject
COPY ${constants.mssqlFolderName}/${constants.commandsFolderName}/ /opt/commands
RUN ["/bin/bash", "/opt/commands/start.sh"]
`);
}
private async createFile(filePath: string, content: string): Promise<void> {
this.logToOutput(`Creating file ${filePath}, content:${content}`);
await fse.writeFile(filePath, content);
}
public async cleanDockerObjects(commandToGetObjects: string, commandsToClean: string[]): Promise<void> {
const currentIds = await utils.executeCommand(commandToGetObjects, this._outputChannel);
if (currentIds) {
const ids = currentIds.split(/\r?\n/);
for (let index = 0; index < ids.length; index++) {
const id = ids[index];
if (id) {
for (let commandId = 0; commandId < commandsToClean.length; commandId++) {
const command = commandsToClean[commandId];
await utils.executeCommand(`${command} ${id}`, this._outputChannel);
}
}
}
}
}
}