sleeveforarm
Version:
Making Azure Easy
285 lines (258 loc) • 11.9 kB
text/typescript
import * as Crypto from "crypto";
import * as fs from "fs-extra";
import * as GeneratePassword from "generate-password";
import * as Path from "path";
import * as Winston from "winston";
import BaseDeployStorageResource from "./BaseDeployStorageResource";
import * as CommonUtilities from "./common-utilities";
import * as data from "./data";
import * as IInfrastructure from "./IInfrastructure";
import INamePassword from "./INamePassword";
import IStorageResource from "./IStorageResource";
import * as KeyVaultInfra from "./keyvaultInfrastructure";
import MySqlAzure from "./mysql-azure";
import PromiseGate from "./promiseGate";
import * as Resource from "./resource";
import * as ServiceEnvironmentUtilities from "./serviceEnvironmentUtilities";
export interface ISqlCreateResult {
fullyQualifiedDomainName: string;
name: string;
}
export class BaseDeployMySqlAzureInfrastructure
implements BaseDeployStorageResource {
private readonly environmentVariablesValues: Array<[string, string]> = [];
constructor(private baseMySqlAzureInfrastructure:
MySqlAzureInfrastructure,
createResult: ISqlCreateResult) {
const baseName = baseMySqlAzureInfrastructure.getBaseName();
const hostVariableName =
`${baseName}${ServiceEnvironmentUtilities.resourceHostSuffix}`;
const userVariableName =
`${baseName}${ServiceEnvironmentUtilities.resourceUserSuffix}`;
const passwordVariableName =
`${baseName}${ServiceEnvironmentUtilities.resourcePasswordSuffix}`;
this.environmentVariablesValues.push([hostVariableName,
createResult.fullyQualifiedDomainName]);
this.environmentVariablesValues.push([userVariableName,
`${baseMySqlAzureInfrastructure.securityName}@${createResult.name}`]);
this.environmentVariablesValues.push([passwordVariableName,
baseMySqlAzureInfrastructure.password]);
}
/**
* Returns a list name/value pairs for environment
* variables to describe how to connect to this resource.
*/
public getEnvironmentVariables(): Array<[string, string]> {
return this.environmentVariablesValues;
}
/**
* Creates a firewall on the storage resource with the given
* name for the given ipAddress.
*/
public async setFirewallRule(nameOfResourceSettingRule: string,
ipAddress: string)
: Promise<this> {
await this.baseMySqlAzureInfrastructure
.setFirewallRule(nameOfResourceSettingRule, ipAddress);
return this;
}
}
export class MySqlAzureInfrastructure extends MySqlAzure
// tslint:disable-next-line:max-line-length
implements IInfrastructure.IInfrastructure<BaseDeployMySqlAzureInfrastructure>,
IStorageResource, INamePassword {
public securityName: string;
public password: string;
public isStorageResource: boolean = true;
public mySqlAzureFullName: string;
private readonly promiseGate = new PromiseGate();
public getBaseName() {
return this.baseName;
}
public initialize(resource: Resource.Resource | null,
targetDirectoryPath: string): this {
super.initialize(resource, targetDirectoryPath);
if (resource !== null) {
Object.assign(this, resource);
}
return this;
}
public async setup(): Promise<void> {
return await MySqlAzureInfrastructure.internalSetup(__filename,
this.targetDirectoryPath, data.data.MySQLNameLength);
}
public async hydrate(resourcesInEnvironment: Resource.Resource[],
deploymentType: Resource.DeployType)
: Promise<this> {
await super.hydrate(resourcesInEnvironment, deploymentType);
if (this.mySqlAzureFullName === undefined) {
this.mySqlAzureFullName = (this.resourceGroup.resourceGroupName +
this.baseName).toLowerCase();
}
this.securityName = this.baseName;
this.password = GeneratePassword.generate({
// tslint:disable-next-line:max-line-length
// This is a combination of https://docs.microsoft.com/en-us/sql/relational-databases/security/strong-passwords
// and https://technet.microsoft.com/en-us/library/cc956689.aspx.
// The later came up when setting a password!
// The & character got added because it's a reserved command,
// even in strings. If we want to use it we need to wrap it
// as '"&"'.
// I also took out double quotes and ^ because they seem to
// disappear when I set them on Keyvault. I submitted a bug on that.
exclude: "^\"&\/:|<>+=.'[]{}(),;?*!@",
length: 32,
numbers: true,
strict: true,
symbols: false,
uppercase: true
});
// BUGBUG: To meet the symbol requirement for now.
this.password += "$";
return this;
}
public async deployResource(): Promise<this> {
try {
await this.resourceGroup.getBaseDeployClassInstance();
const promisesToWaitFor = [];
const resourceGroupName = this.resourceGroup.resourceGroupName;
const createResult = await CommonUtilities.runAzCommand(
`az mysql server create \
--resource-group ${resourceGroupName} --name ${this.mySqlAzureFullName} \
--admin-user ${this.securityName} --admin-password ${this.password} \
--ssl-enforcement Enabled`, CommonUtilities.azCommandOutputs.json);
this.promiseGate.openGateSuccess(
new BaseDeployMySqlAzureInfrastructure(this, createResult));
const keyVault =
CommonUtilities
.findGlobalDefaultResourceByType(
this.resourcesInEnvironment,
KeyVaultInfra.KeyVaultInfrastructure) as KeyVaultInfra.KeyVaultInfrastructure;
promisesToWaitFor.push(
keyVault
.getBaseDeployClassInstance()
.then((keyVaultBaseClass) => {
return keyVaultBaseClass
.setSecret(this.securityName, this.password);
}));
if (this.deploymentType === Resource.DeployType.LocalDevelopment) {
promisesToWaitFor.push(this.setFirewallAllowAll());
}
const scriptPaths: string [] = [];
for (const checkScriptPath of
this.pathToMySqlInitializationScripts) {
const scriptPath =
Path.isAbsolute(checkScriptPath) ? checkScriptPath :
Path.join(this.targetDirectoryPath, checkScriptPath);
if (await fs.pathExists(scriptPath) === false) {
throw new Error(`Submitted mySql initialization script, \
located at ${scriptPath} for ${this.baseName} does not exist.`);
}
scriptPaths.push(scriptPath);
}
let firewallRuleName;
if (scriptPaths.length !== 0) {
firewallRuleName = await this.setUpFirewallForSqlScript();
for (const scriptPath of scriptPaths) {
promisesToWaitFor.push(this.runMySqlScript(scriptPath));
}
}
try {
await Promise.all(promisesToWaitFor);
} finally {
if (this.deploymentType === Resource.DeployType.Production
&& firewallRuleName) {
await this.removeFirewallRule(firewallRuleName);
}
}
return this;
} catch (err) {
if (!this.promiseGate.isGateOpen) {
this.promiseGate.openGateError(err);
}
throw err;
}
}
public getBaseDeployClassInstance():
Promise<BaseDeployMySqlAzureInfrastructure> {
return this.promiseGate.promise.then(
function(baseClass: BaseDeployMySqlAzureInfrastructure) {
return baseClass;
}
);
}
public async setUpFirewallForSqlScript() {
if (this.deploymentType === Resource.DeployType.LocalDevelopment) {
return Promise.resolve();
}
// We add in the '-e "SHOW DATABASES"' command just to give the
// command something to do in the case that we already have
// permission. Otherwise we won't ever get the failure we are
// expecting.
const initSqlCommand =
`mysql -h ${this.mySqlAzureFullName}.mysql.database.azure.com \
-u ${this.securityName}@${this.mySqlAzureFullName} \
-p${this.password} -v -e "SHOW DATABASES"`;
let devIp: string = "";
const baseFirewallRuleName = Crypto.randomBytes(10).toString("hex");
const re =
/Client with IP address '(.*)' is not allowed to/;
try {
await CommonUtilities.exec(initSqlCommand,
this.targetDirectoryPath);
Winston.debug("THE SYSTEM IS IN A BAD STATE. THERE IS A DANGLING \
FIREWALL RULE THAT WAS MOST LIKELY LEFT OVER FROM A PREVIOUS FAILED DEPLOY. \
WE DON'T AUTOMATICALLY FIX THIS YET. PLEASE SEE \
https://github.com/yaronyg/SleeveForARM/issues/38 FOR MORE DETAILS");
return "";
} catch (err) {
const result = err.message.match(re);
if (result.length !== 2) {
throw new Error(`Search for dev IP failed with ${err}`);
}
devIp = result[1];
}
if (devIp === "") {
throw new Error("Call to get our IP failed!");
}
return await this.setFirewallRule(baseFirewallRuleName, devIp);
}
public async runMySqlScript(pathToScript: string) {
const initSqlCommand =
`mysql -h ${this.mySqlAzureFullName}.mysql.database.azure.com \
-u ${this.securityName}@${this.mySqlAzureFullName} \
-p${this.password} -v < "${pathToScript}"`;
// tslint:disable-next-line:max-line-length
return await CommonUtilities.retryAfterFailure<CommonUtilities.IExecOutput> (async () => {
return await CommonUtilities.exec(initSqlCommand,
this.targetDirectoryPath);
}, 60);
}
public async setFirewallRule(nameOfResourceSettingRule: string,
ipAddress: string) {
const ipNoDots = ipAddress.replace(/\./g, "");
const ruleName = `${nameOfResourceSettingRule}${ipNoDots}`;
await CommonUtilities.runAzCommand(
`az mysql server firewall-rule create \
--resource-group ${this.resourceGroup.resourceGroupName} \
--server ${this.mySqlAzureFullName} \
--name ${ruleName} --start-ip-address ${ipAddress} \
--end-ip-address ${ipAddress}`);
return ruleName;
}
public setFirewallAllowAll() {
return CommonUtilities.runAzCommand(
`az mysql server firewall-rule create \
--resource-group ${this.resourceGroup.resourceGroupName} \
--server ${this.mySqlAzureFullName} \
--name ${this.baseName}AllAccess --start-ip-address 0.0.0.0 \
--end-ip-address 255.255.255.255`);
}
private removeFirewallRule(name: string) {
return CommonUtilities.runAzCommand(
`az mysql server firewall-rule delete \
--resource-group ${this.resourceGroup.resourceGroupName} \
--server-name ${this.mySqlAzureFullName} \
--name ${name} --yes`, CommonUtilities.azCommandOutputs.string);
}
}