* Azure MySQL Action

* Addressed review comments

* code fixes
This commit is contained in:
Ashish Ranjan 2019-11-01 14:46:53 +05:30 коммит произвёл GitHub
Родитель 5f9b34cdfb
Коммит 0871478cfe
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
18 изменённых файлов: 6875 добавлений и 310 удалений

383
.gitignore поставляемый
Просмотреть файл

@ -1,330 +1,93 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
__tests__/runner/*
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# comment out in distribution branches
node_modules/
# Visual Studio 6 build log
*.plg
# Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Visual Studio 6 workspace options file
*.opt
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Paket dependency manager
.paket/paket.exe
paket-files/
# Coverage directory used by tools like istanbul
coverage
*.lcov
# FAKE - F# Make
.fake/
# nyc test coverage
.nyc_output
# JetBrains Rider
.idea/
*.sln.iml
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# CodeRush
.cr/
# Bower dependency directory (https://bower.io/)
bower_components
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# node-waf configuration
.lock-wscript
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Tabs Studio
*.tss
# Dependency directories
jspm_packages/
# Telerik's JustMock configuration file
*.jmconfig
# TypeScript v1 declaration files
typings/
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# TypeScript cache
*.tsbuildinfo
# OpenCover UI analysis results
OpenCover/
# Optional npm cache directory
.npm
# Azure Stream Analytics local run output
ASALocalRun/
# Optional eslint cache
.eslintcache
# MSBuild Binary and Structured Log
*.binlog
# Optional REPL history
.node_repl_history
# NVidia Nsight GPU debugger configuration file
*.nvuser
# Output of 'npm pack'
*.tgz
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/

17
action.yml Normal file
Просмотреть файл

@ -0,0 +1,17 @@
name: 'Azure MYSQL Deploy'
description: 'DeploytoAzureMySQLdatabaseusingSQLscriptfiles'
inputs:
server-name:
description: 'ServernameofAzureDBforMysql.Example:fabrikam.mysql.database.azure.com.WhenyouconnectusingMysqlWorkbench,thisisthesamevaluethatisusedforHostnameinParameters'
required: true
connection-string:
description: 'Theconnectionstring,includingauthenticationinformation,fortheAzureMySQLServer.'
required: true
sql-file:
description: 'PathtoSQLscriptfile.*.sqlorafoldertodeploy'
required: true
arguments:
description: 'AdditionaloptionssupportedbymysqlsimpleSQLshell.TheseoptionswillbeappliedwhenexecutingthegivenfileontheAzureDBforMysql.'
runs:
using: 'node12'
main: 'lib/main.js'

11
jest.config.js Normal file
Просмотреть файл

@ -0,0 +1,11 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}

37
lib/AzureMySqlAction.js Normal file
Просмотреть файл

@ -0,0 +1,37 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const core = __importStar(require("@actions/core"));
const exec = __importStar(require("@actions/exec"));
const AzureMySqlActionHelper_1 = __importDefault(require("./AzureMySqlActionHelper"));
class AzureMySqlAction {
constructor(inputs) {
this._inputs = inputs;
}
execute() {
return __awaiter(this, void 0, void 0, function* () {
core.debug('Begin executing action...');
let mySqlClientPath = yield AzureMySqlActionHelper_1.default.getMySqlClientPath();
yield exec.exec(`"${mySqlClientPath}" -h ${this._inputs.serverName} -D ${this._inputs.connectionString.database} -u ${this._inputs.connectionString.userId} --password="${this._inputs.connectionString.password}" ${this._inputs.additionalArguments} -e "source ${this._inputs.sqlFile}"`);
console.log('Successfully executed sql file on target database');
});
}
}
exports.default = AzureMySqlAction;

Просмотреть файл

@ -0,0 +1,142 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const core = __importStar(require("@actions/core"));
const io = __importStar(require("@actions/io"));
const os = __importStar(require("os"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const glob = __importStar(require("glob"));
const winreg_1 = __importDefault(require("winreg"));
const IS_WINDOWS = process.platform === 'win32';
class AzureMySqlActionHelper {
static getMySqlClientPath() {
return __awaiter(this, void 0, void 0, function* () {
core.debug(`Getting location of MySql client on ${os.hostname()}`);
if (IS_WINDOWS) {
return this._getMySqlClientOnWindows();
}
else {
return this._getMySqlClientOnLinux();
}
});
}
static resolveFilePath(filePathPattern) {
let filePath = filePathPattern;
if (glob.hasMagic(filePathPattern)) {
let matchedFiles = glob.sync(filePathPattern);
if (matchedFiles.length === 0) {
throw new Error(`No files found matching pattern ${filePathPattern}`);
}
if (matchedFiles.length > 1) {
throw new Error(`Muliple files found matching pattern ${filePathPattern}`);
}
filePath = matchedFiles[0];
}
if (!fs.existsSync(filePath)) {
throw new Error(`Unable to find file at location: ${filePath}`);
}
return filePath;
}
static getRegistrySubKeys(path) {
return new Promise((resolve) => {
core.debug(`Getting sub-keys at registry path: HKLM:${path}`);
let regKey = new winreg_1.default({
hive: winreg_1.default.HKLM,
key: path
});
regKey.keys((error, result) => {
return !!error ? '' : resolve(result);
});
});
}
static getRegistryValue(registryKey, name) {
return new Promise((resolve) => {
core.debug(`Getting registry value ${name} at path: HKLM:${registryKey.key}`);
registryKey.get(name, (error, result) => {
resolve(!!error ? '' : result.value);
});
});
}
static registryKeyExists(path) {
core.debug(`Checking if registry key 'HKLM:${path}' exists.`);
return new Promise((resolve) => {
let regKey = new winreg_1.default({
hive: winreg_1.default.HKLM,
key: path
});
regKey.keyExists((error, result) => {
resolve(!!error ? false : result);
});
});
}
static _getMySqlClientOnWindows() {
return __awaiter(this, void 0, void 0, function* () {
let mySqlClientRegistryKey = path.join('\\', 'Software', 'MySQL AB');
let mySqlClientRegistryKeyWow6432 = path.join('\\', 'Software', 'Wow6432Node', 'MySQL AB');
let mySqlClientPath = '';
if (yield AzureMySqlActionHelper.registryKeyExists(mySqlClientRegistryKey)) {
mySqlClientPath = yield this._getMySqlClientPathFromRegistry(mySqlClientRegistryKey);
}
if (!mySqlClientPath && (yield AzureMySqlActionHelper.registryKeyExists(mySqlClientRegistryKeyWow6432))) {
mySqlClientPath = yield this._getMySqlClientPathFromRegistry(mySqlClientRegistryKeyWow6432);
}
if (!mySqlClientPath) {
core.debug(`Unable to find mysql client executable on ${os.hostname()} from registry.`);
core.debug(`Getting location of mysql.exe from PATH environment variable.`);
mySqlClientPath = yield io.which('mysql', false);
}
if (mySqlClientPath) {
core.debug(`MySql client found at path ${mySqlClientPath}`);
return mySqlClientPath;
}
else {
throw new Error(`Unable to find mysql client executable on ${os.hostname()}.`);
}
});
}
static _getMySqlClientPathFromRegistry(registryPath) {
return __awaiter(this, void 0, void 0, function* () {
core.debug(`Getting location of mysql.exe from registryPath HKLM:${registryPath}`);
let registrySubKeys = yield AzureMySqlActionHelper.getRegistrySubKeys(registryPath);
for (let registryKey of registrySubKeys) {
if (registryKey.key.match('MySQL Server')) {
let mySqlServerPath = yield AzureMySqlActionHelper.getRegistryValue(registryKey, 'Location');
if (!!mySqlServerPath) {
let mySqlClientExecutablePath = path.join(mySqlServerPath, 'bin', 'mysql.exe');
if (fs.existsSync(mySqlClientExecutablePath)) {
core.debug(`MySQL client executable found at path ${mySqlClientExecutablePath}`);
return mySqlClientExecutablePath;
}
}
}
}
return '';
});
}
static _getMySqlClientOnLinux() {
return __awaiter(this, void 0, void 0, function* () {
let mySqlClientPath = yield io.which('mysql', true);
core.debug(`MySQL client found at path ${mySqlClientPath}`);
return mySqlClientPath;
});
}
}
exports.default = AzureMySqlActionHelper;

Просмотреть файл

@ -0,0 +1,191 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const core = __importStar(require("@actions/core"));
const AzureRestClient_1 = require("azure-actions-webclient/AzureRestClient");
class AzureMySqlResourceManager {
constructor(resourceAuthorizer) {
// making the constructor private, so that object initialization can only be done by the class factory GetResourceManager
this._restClient = new AzureRestClient_1.ServiceClient(resourceAuthorizer);
}
static getResourceManager(serverName, resourceAuthorizer) {
return __awaiter(this, void 0, void 0, function* () {
// a factory method to return asynchronously created object
let resourceManager = new AzureMySqlResourceManager(resourceAuthorizer);
yield resourceManager._populateMySqlServerData(serverName);
return resourceManager;
});
}
getMySqlServer() {
return this._resource;
}
addFirewallRule(startIpAddress, endIpAddress) {
return __awaiter(this, void 0, void 0, function* () {
let firewallRuleName = `ClientIPAddress_${Date.now()}`;
let httpRequest = {
method: 'PUT',
uri: this._restClient.getRequestUri(`/${this._resource.id}/firewallRules/${firewallRuleName}`, {}, [], '2017-12-01'),
body: JSON.stringify({
'properties': {
'startIpAddress': startIpAddress,
'endIpAddress': endIpAddress
}
})
};
try {
let httpResponse = yield this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode === 202) {
let asyncOperationResponse = yield this._getLongRunningOperationResult(httpResponse);
if (asyncOperationResponse.statusCode === 200 && asyncOperationResponse.body['status'] && asyncOperationResponse.body['status'].toLowerCase() === 'succeeded') {
core.debug(JSON.stringify(asyncOperationResponse.body));
return this.getFirewallRule(firewallRuleName);
}
else {
throw AzureRestClient_1.ToError(asyncOperationResponse);
}
}
else if (httpResponse.statusCode === 200 || httpResponse.statusCode === 201) {
return httpResponse.body;
}
throw AzureRestClient_1.ToError(httpResponse);
}
catch (error) {
if (error instanceof AzureRestClient_1.AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
});
}
removeFirewallRule(firewallRule) {
return __awaiter(this, void 0, void 0, function* () {
let httpRequest = {
method: 'DELETE',
uri: this._restClient.getRequestUri(`/${this._resource.id}/firewallRules/${firewallRule.name}`, {}, [], '2017-12-01')
};
try {
let httpResponse = yield this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode === 202) {
let asyncOperationResponse = yield this._getLongRunningOperationResult(httpResponse);
if (asyncOperationResponse.statusCode === 200 && asyncOperationResponse.body['status'] && asyncOperationResponse.body['status'].toLowerCase() === 'succeeded') {
core.debug(JSON.stringify(asyncOperationResponse.body));
}
else {
throw AzureRestClient_1.ToError(asyncOperationResponse);
}
}
else if (httpResponse.statusCode !== 200 && httpResponse.statusCode !== 204) {
throw AzureRestClient_1.ToError(httpResponse);
}
}
catch (error) {
if (error instanceof AzureRestClient_1.AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
});
}
getFirewallRule(ruleName) {
return __awaiter(this, void 0, void 0, function* () {
let httpRequest = {
method: 'GET',
uri: this._restClient.getRequestUri(`/${this._resource.id}/firewallRules/${ruleName}`, {}, [], '2017-12-01')
};
try {
let httpResponse = yield this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode !== 200) {
throw AzureRestClient_1.ToError(httpResponse);
}
return httpResponse.body;
}
catch (error) {
if (error instanceof AzureRestClient_1.AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
});
}
_getLongRunningOperationResult(response) {
return __awaiter(this, void 0, void 0, function* () {
let timeoutInMinutes = 2;
let timeout = new Date().getTime() + timeoutInMinutes * 60 * 1000;
let request = {
method: 'GET',
uri: response.headers['azure-asyncoperation'] || response.headers['location']
};
if (!request.uri) {
throw new Error('Unable to find the Azure-Async operation polling URI.');
}
while (true) {
response = yield this._restClient.beginRequest(request);
if (response.statusCode === 202 || (response.body && (response.body.status == 'Accepted' || response.body.status == 'Running' || response.body.status == 'InProgress'))) {
if (timeout < new Date().getTime()) {
throw new Error(`Async polling request timed out. URI: ${request.uri}`);
}
let retryAfterInterval = response.headers['retry-after'] && parseInt(response.headers['retry-after']) || 15;
yield this._sleep(retryAfterInterval);
}
else {
break;
}
}
return response;
});
}
_populateMySqlServerData(serverName) {
return __awaiter(this, void 0, void 0, function* () {
// trim the cloud hostname suffix from servername
serverName = serverName.split('.')[0];
let httpRequest = {
method: 'GET',
uri: this._restClient.getRequestUri('//subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/servers', {}, [], '2017-12-01')
};
core.debug(`Get MySQL server '${serverName}' details`);
try {
let httpResponse = yield this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode !== 200) {
throw AzureRestClient_1.ToError(httpResponse);
}
let sqlServers = httpResponse.body && httpResponse.body.value;
if (sqlServers && sqlServers.length > 0) {
this._resource = sqlServers.filter((sqlResource) => sqlResource.name === serverName)[0];
if (!this._resource) {
throw new Error(`Unable to get details of MySQL server ${serverName}. MySql server '${serverName}' was not found in the subscription.`);
}
core.debug(JSON.stringify(this._resource));
}
else {
throw new Error(`Unable to get details of MySQL server ${serverName}. No MySQL servers were found in the subscription.`);
}
}
catch (error) {
if (error instanceof AzureRestClient_1.AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
});
}
_sleep(sleepDurationInSeconds) {
return new Promise((resolve) => {
setTimeout(resolve, sleepDurationInSeconds * 1000);
});
}
}
exports.default = AzureMySqlResourceManager;

84
lib/FirewallManager.js Normal file
Просмотреть файл

@ -0,0 +1,84 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const core = __importStar(require("@actions/core"));
const exec = __importStar(require("@actions/exec"));
const AzureMySqlActionHelper_1 = __importDefault(require("./AzureMySqlActionHelper"));
const ipv4MatchPattern = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
class FirewallManager {
constructor(azureMySqlResourceManager) {
this._resourceManager = azureMySqlResourceManager;
}
addFirewallRule(serverName, connectionString) {
return __awaiter(this, void 0, void 0, function* () {
let ipAddress = yield this._detectIPAddress(serverName, connectionString);
if (!ipAddress) {
core.debug(`Client has access to MySql server. Skip adding firewall exception.`);
return;
}
console.log(`Client does not have access to MySql server. Adding firewall exception for client's IP address.`);
this._firewallRule = yield this._resourceManager.addFirewallRule(ipAddress, ipAddress);
core.debug(JSON.stringify(this._firewallRule));
console.log(`Successfully added firewall rule ${this._firewallRule.name}.`);
});
}
removeFirewallRule() {
return __awaiter(this, void 0, void 0, function* () {
if (this._firewallRule) {
console.log(`Removing firewall rule '${this._firewallRule.name}'.`);
yield this._resourceManager.removeFirewallRule(this._firewallRule);
console.log('Successfully removed firewall rule.');
}
else {
core.debug('No firewall exception was added.');
}
});
}
_detectIPAddress(serverName, connectionString) {
return __awaiter(this, void 0, void 0, function* () {
let mySqlClientPath = yield AzureMySqlActionHelper_1.default.getMySqlClientPath();
let ipAddress = '';
let mySqlError = '';
try {
core.debug(`Validating if client has access to MySql Server '${serverName}'.`);
core.debug(`"${mySqlClientPath}" -h ${serverName} -u "${connectionString.userId}" -e "show databases"`);
yield exec.exec(`"${mySqlClientPath}" -h ${serverName} -u "${connectionString.userId}" --password="${connectionString.password}" -e "show databases"`, [], {
silent: true,
listeners: {
stderr: (data) => mySqlError += data.toString()
}
});
}
catch (error) {
core.debug(mySqlError);
let ipAddresses = mySqlError.match(ipv4MatchPattern);
if (!!ipAddresses) {
ipAddress = ipAddresses[0];
}
else {
throw new Error(`Failed to add firewall rule. Unable to detect client IP Address. ${mySqlError} ${error}`);
}
}
//ipAddress will be an empty string if client has access to SQL server
return ipAddress;
});
}
}
exports.default = FirewallManager;

Просмотреть файл

@ -0,0 +1,107 @@
"use strict";
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* The basic format of a connection string includes a series of keyword/value pairs separated by semicolons.
* The equal sign (=) connects each keyword and its value. (Ex: Key1=Val1;Key2=Val2)
*
* Following rules are to be followed while passing special characters in values:
1. To include values that contain a semicolon, single-quote character, or double-quote character, the value must be enclosed in double quotation marks.
2. If the value contains both a semicolon and a double-quote character, the value can be enclosed in single quotation marks.
3. The single quotation mark is also useful if the value starts with a double-quote character. Conversely, the double quotation mark can be used if the value starts with a single quotation mark.
4. If the value contains both single-quote and double-quote characters, the quotation mark character used to enclose the value must be doubled every time it occurs within the value.
Regex used by the parser(connectionStringParserRegex) to parse the VALUE:
('[^']*(''[^']*)*') -> value enclosed with single quotes and has consecutive single quotes
|("[^"]*(""[^"]*)*") -> value enclosed with double quotes and has consecutive double quotes
|((?!['"])[^;]*)) -> value does not start with quotes does not contain any special character. Here we do a positive lookahead to ensure that the value doesn't start with quotes which should have been handled in previous cases
Regex used to validate the entire connection string:
A connection string is considered valid if it is a series of key/value pairs separated by semicolons. Each key/value pair must satisy the connectionStringParserRegex to ensure it is a valid key/value pair.
^[;\s]*{KeyValueRegex}(;[;\s]*{KeyValueRegex})*[;\s]*$
where KeyValueRegex = ([\w\s]+=(?:('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*))))
*/
const core = __importStar(require("@actions/core"));
const connectionStringParserRegex = /(?<key>[\w\s]+)=(?<val>('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*))/g;
const connectionStringTester = /^[;\s]*([\w\s]+=(?:('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*)))(;[;\s]*([\w\s]+=(?:('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*))))*[;\s]*$/;
class MySqlConnectionStringBuilder {
constructor(connectionString) {
this._connectionString = '';
this._connectionString = connectionString;
this._validateConnectionString();
this._parsedConnectionString = this._parseConnectionString();
}
get connectionString() {
return this._connectionString;
}
get userId() {
return this._parsedConnectionString.userId;
}
get password() {
return this._parsedConnectionString.password;
}
get database() {
return this._parsedConnectionString.database;
}
_validateConnectionString() {
if (!connectionStringTester.test(this._connectionString)) {
throw new Error('Invalid connection string. A valid connection string is a series of keyword/value pairs separated by semi-colons. If there are any special characters like quotes, semi-colons in the keyword value, enclose the value within quotes. Refer this link for more info on conneciton string https://aka.ms/sqlconnectionstring');
}
}
_parseConnectionString() {
let result = this._connectionString.matchAll(connectionStringParserRegex);
let parsedConnectionString = {};
for (let match of result) {
if (match.groups) {
let key = match.groups.key.trim();
let val = match.groups.val.trim();
/**
* If the first character of val is a single/double quote and there are two consecutive single/double quotes in between,
* convert the consecutive single/double quote characters into one single/double quote character respectively (Point no. 4 above)
*/
if (val[0] === "'") {
val = val.slice(1, -1);
val = val.replace(/''/g, "'");
}
else if (val[0] === '"') {
val = val.slice(1, -1);
val = val.replace(/""/g, '"');
}
switch (key.toLowerCase()) {
case 'user id':
case 'uid': {
parsedConnectionString.userId = val;
break;
}
case 'password':
case 'pwd': {
parsedConnectionString.password = val;
// masking the connection string password to prevent logging to console
core.setSecret(val);
break;
}
case 'database': {
parsedConnectionString.database = val;
break;
}
case 'server': {
parsedConnectionString.server = val;
break;
}
}
}
}
if (!parsedConnectionString.userId || !parsedConnectionString.password || !parsedConnectionString.database) {
throw new Error(`Missing required keys in connection string. Please ensure that the keys 'User Id', 'Password', 'Database' are provided in the connection string.`);
}
return parsedConnectionString;
}
}
exports.default = MySqlConnectionStringBuilder;

77
lib/main.js Normal file
Просмотреть файл

@ -0,0 +1,77 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const core = __importStar(require("@actions/core"));
const crypto = __importStar(require("crypto"));
const path = __importStar(require("path"));
const AuthorizerFactory_1 = require("azure-actions-webclient/AuthorizerFactory");
const AzureMySqlActionHelper_1 = __importDefault(require("./AzureMySqlActionHelper"));
const AzureMySqlAction_1 = __importDefault(require("./AzureMySqlAction"));
const FirewallManager_1 = __importDefault(require("./FirewallManager"));
const AzureMySqlResourceManager_1 = __importDefault(require("./AzureMySqlResourceManager"));
const MySqlConnectionStringBuilder_1 = __importDefault(require("./MySqlConnectionStringBuilder"));
let userAgentPrefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USER_AGENT}` : "";
function run() {
return __awaiter(this, void 0, void 0, function* () {
let firewallManager;
try {
// Set user agent variable
let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex');
let actionName = 'AzureMySqlAction';
let userAgentString = (!!userAgentPrefix ? `${userAgentPrefix}+` : '') + `GITHUBACTIONS_${actionName}_${usrAgentRepo}`;
core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString);
let inputs = getInputs();
let azureMySqlAction = new AzureMySqlAction_1.default(inputs);
let azureResourceAuthorizer = yield AuthorizerFactory_1.AuthorizerFactory.getAuthorizer();
let azureMySqlResourceManager = yield AzureMySqlResourceManager_1.default.getResourceManager(inputs.serverName, azureResourceAuthorizer);
firewallManager = new FirewallManager_1.default(azureMySqlResourceManager);
yield firewallManager.addFirewallRule(inputs.serverName, inputs.connectionString);
yield azureMySqlAction.execute();
}
catch (error) {
core.setFailed(error.message);
}
finally {
if (firewallManager) {
yield firewallManager.removeFirewallRule();
}
// Reset AZURE_HTTP_USER_AGENT
core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentPrefix);
}
});
}
exports.run = run;
function getInputs() {
let serverName = core.getInput('server-name', { required: true });
let connectionString = core.getInput('connection-string', { required: true });
let connectionStringBuilder = new MySqlConnectionStringBuilder_1.default(connectionString);
let sqlFile = AzureMySqlActionHelper_1.default.resolveFilePath(core.getInput('sql-file', { required: true }));
if (path.extname(sqlFile).toLowerCase() !== '.sql') {
throw new Error(`Invalid sql file path provided as input ${sqlFile}`);
}
let additionalArguments = core.getInput('arguments');
return {
serverName: serverName,
connectionString: connectionStringBuilder,
sqlFile: sqlFile,
additionalArguments: additionalArguments
};
}
run();

5377
package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

40
package.json Normal file
Просмотреть файл

@ -0,0 +1,40 @@
{
"name": "azure-mysql-action",
"version": "1.0.0",
"private": true,
"description": "Azure MySql Action",
"main": "lib/main.js",
"scripts": {
"build": "tsc",
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Azure/mysql-action.git"
},
"keywords": [
"actions",
"node",
"setup"
],
"author": "Microsoft",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.2.0",
"@actions/exec": "^1.0.1",
"@actions/io": "^1.0.1",
"azure-actions-webclient": "^1.0.4",
"glob": "^7.1.5",
"winreg": "^1.2.4"
},
"devDependencies": {
"@types/glob": "^7.1.1",
"@types/jest": "^24.0.13",
"@types/node": "^12.0.4",
"@types/winreg": "^1.2.30",
"jest": "^24.8.0",
"jest-circus": "^24.7.1",
"ts-jest": "^24.0.2",
"typescript": "^3.5.1"
}
}

28
src/AzureMySqlAction.ts Normal file
Просмотреть файл

@ -0,0 +1,28 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import AzureMySqlActionHelper from './AzureMySqlActionHelper';
import MySqlConnectionStringBuilder from './MySqlConnectionStringBuilder';
export interface IActionInputs {
serverName: string;
connectionString: MySqlConnectionStringBuilder;
sqlFile: string;
additionalArguments: string;
}
export default class AzureMySqlAction {
constructor(inputs: IActionInputs) {
this._inputs = inputs;
}
public async execute() {
core.debug('Begin executing action...');
let mySqlClientPath = await AzureMySqlActionHelper.getMySqlClientPath();
await exec.exec(`"${mySqlClientPath}" -h ${this._inputs.serverName} -D ${this._inputs.connectionString.database} -u ${this._inputs.connectionString.userId} --password='${this._inputs.connectionString.password}' ${this._inputs.additionalArguments} -e "source ${this._inputs.sqlFile}"`)
console.log('Successfully executed sql file on target database');
}
private _inputs: IActionInputs;
}

Просмотреть файл

@ -0,0 +1,134 @@
import * as core from '@actions/core';
import * as io from '@actions/io';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import * as glob from 'glob';
import winreg from 'winreg';
const IS_WINDOWS = process.platform === 'win32';
export default class AzureMySqlActionHelper {
public static async getMySqlClientPath(): Promise<string> {
core.debug(`Getting location of MySql client on ${os.hostname()}`);
if (IS_WINDOWS) {
return this._getMySqlClientOnWindows();
}
else {
return this._getMySqlClientOnLinux();
}
}
public static resolveFilePath(filePathPattern: string): string {
let filePath = filePathPattern;
if (glob.hasMagic(filePathPattern)) {
let matchedFiles: string[] = glob.sync(filePathPattern);
if (matchedFiles.length === 0) {
throw new Error(`No files found matching pattern ${filePathPattern}`);
}
if (matchedFiles.length > 1) {
throw new Error(`Muliple files found matching pattern ${filePathPattern}`);
}
filePath = matchedFiles[0];
}
if (!fs.existsSync(filePath)) {
throw new Error(`Unable to find file at location: ${filePath}`);
}
return filePath;
}
public static getRegistrySubKeys(path: string): Promise<winreg.Registry[]> {
return new Promise((resolve) => {
core.debug(`Getting sub-keys at registry path: HKLM:${path}`);
let regKey = new winreg({
hive: winreg.HKLM,
key: path
});
regKey.keys((error, result) => {
return !!error ? '' : resolve(result);
})
});
}
public static getRegistryValue(registryKey: winreg.Registry, name: string): Promise<string> {
return new Promise((resolve) => {
core.debug(`Getting registry value ${name} at path: HKLM:${registryKey.key}`);
registryKey.get(name, (error, result: winreg.RegistryItem) => {
resolve(!!error ? '' : result.value);
});
});
}
public static registryKeyExists(path: string): Promise<boolean> {
core.debug(`Checking if registry key 'HKLM:${path}' exists.`);
return new Promise((resolve) => {
let regKey = new winreg({
hive: winreg.HKLM,
key: path
});
regKey.keyExists((error, result: boolean) => {
resolve(!!error ? false : result);
})
});
}
private static async _getMySqlClientOnWindows(): Promise<string> {
let mySqlClientRegistryKey = path.join('\\', 'Software', 'MySQL AB');
let mySqlClientRegistryKeyWow6432 = path.join('\\', 'Software', 'Wow6432Node', 'MySQL AB');
let mySqlClientPath = '';
if (await AzureMySqlActionHelper.registryKeyExists(mySqlClientRegistryKey)) {
mySqlClientPath = await this._getMySqlClientPathFromRegistry(mySqlClientRegistryKey);
}
if (!mySqlClientPath && await AzureMySqlActionHelper.registryKeyExists(mySqlClientRegistryKeyWow6432)) {
mySqlClientPath = await this._getMySqlClientPathFromRegistry(mySqlClientRegistryKeyWow6432);
}
if (!mySqlClientPath) {
core.debug(`Unable to find mysql client executable on ${os.hostname()} from registry.`);
core.debug(`Getting location of mysql.exe from PATH environment variable.`);
mySqlClientPath = await io.which('mysql', false);
}
if (mySqlClientPath) {
core.debug(`MySql client found at path ${mySqlClientPath}`);
return mySqlClientPath;
}
else {
throw new Error(`Unable to find mysql client executable on ${os.hostname()}.`);
}
}
private static async _getMySqlClientPathFromRegistry(registryPath: string): Promise<string> {
core.debug(`Getting location of mysql.exe from registryPath HKLM:${registryPath}`);
let registrySubKeys = await AzureMySqlActionHelper.getRegistrySubKeys(registryPath);
for (let registryKey of registrySubKeys) {
if (registryKey.key.match('MySQL Server')) {
let mySqlServerPath = await AzureMySqlActionHelper.getRegistryValue(registryKey, 'Location');
if (!!mySqlServerPath) {
let mySqlClientExecutablePath = path.join(mySqlServerPath, 'bin', 'mysql.exe');
if (fs.existsSync(mySqlClientExecutablePath)) {
core.debug(`MySQL client executable found at path ${mySqlClientExecutablePath}`);
return mySqlClientExecutablePath;
}
}
}
}
return '';
}
private static async _getMySqlClientOnLinux(): Promise<string> {
let mySqlClientPath = await io.which('mysql', true);
core.debug(`MySQL client found at path ${mySqlClientPath}`);
return mySqlClientPath;
}
}

Просмотреть файл

@ -0,0 +1,233 @@
import * as core from '@actions/core';
import { IAuthorizer } from 'azure-actions-webclient/Authorizer/IAuthorizer';
import { WebRequest, WebResponse } from 'azure-actions-webclient/WebClient';
import { ServiceClient as AzureRestClient, ToError, AzureError } from 'azure-actions-webclient/AzureRestClient';
export interface AzureMySqlServer {
id: string;
location: string;
name: string;
properties: {
administratorLogin: string;
earliestRestoreDate: string;
fullyQualifiedDomainName: string;
masterServerId: string;
replicaCapacity: number;
replicationRole: string;
storageProfile: {
backupRetentionDays: string;
geoRedundantBackup: string;
storageAutogrow: string;
storageMB: number;
};
userVisibleState: string;
sslEnforcement: string;
version: string;
};
type: string;
sku: {
capacity: number;
family: string;
name: string;
size: string;
tier: string;
}
}
export interface FirewallRule {
id: string;
name: string;
type: string;
properties: {
startIpAddress: string;
endIpAddress: string;
}
}
export default class AzureMySqlResourceManager {
private constructor(resourceAuthorizer: IAuthorizer) {
// making the constructor private, so that object initialization can only be done by the class factory GetResourceManager
this._restClient = new AzureRestClient(resourceAuthorizer);
}
public static async getResourceManager(serverName: string, resourceAuthorizer: IAuthorizer): Promise<AzureMySqlResourceManager> {
// a factory method to return asynchronously created object
let resourceManager = new AzureMySqlResourceManager(resourceAuthorizer);
await resourceManager._populateMySqlServerData(serverName);
return resourceManager;
}
public getMySqlServer() {
return this._resource;
}
public async addFirewallRule(startIpAddress: string, endIpAddress: string): Promise<FirewallRule> {
let firewallRuleName = `ClientIPAddress_${Date.now()}`;
let httpRequest: WebRequest = {
method: 'PUT',
uri: this._restClient.getRequestUri(`/${this._resource!.id}/firewallRules/${firewallRuleName}`, {}, [], '2017-12-01'),
body: JSON.stringify({
'properties': {
'startIpAddress': startIpAddress,
'endIpAddress': endIpAddress
}
})
};
try {
let httpResponse = await this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode === 202) {
let asyncOperationResponse = await this._getLongRunningOperationResult(httpResponse);
if (asyncOperationResponse.statusCode === 200 && asyncOperationResponse.body['status'] && asyncOperationResponse.body['status'].toLowerCase() === 'succeeded') {
core.debug(JSON.stringify(asyncOperationResponse.body));
return this.getFirewallRule(firewallRuleName);
}
else {
throw ToError(asyncOperationResponse);
}
}
else if (httpResponse.statusCode === 200 || httpResponse.statusCode === 201) {
return httpResponse.body as FirewallRule;
}
throw ToError(httpResponse);
}
catch(error) {
if (error instanceof AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
}
public async removeFirewallRule(firewallRule: FirewallRule): Promise<void> {
let httpRequest: WebRequest = {
method: 'DELETE',
uri: this._restClient.getRequestUri(`/${this._resource!.id}/firewallRules/${firewallRule.name}`, {}, [], '2017-12-01')
};
try {
let httpResponse = await this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode === 202) {
let asyncOperationResponse = await this._getLongRunningOperationResult(httpResponse);
if (asyncOperationResponse.statusCode === 200 && asyncOperationResponse.body['status'] && asyncOperationResponse.body['status'].toLowerCase() === 'succeeded') {
core.debug(JSON.stringify(asyncOperationResponse.body));
}
else {
throw ToError(asyncOperationResponse);
}
}
else if (httpResponse.statusCode !== 200 && httpResponse.statusCode !== 204) {
throw ToError(httpResponse);
}
}
catch(error) {
if (error instanceof AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
}
public async getFirewallRule(ruleName: string): Promise<FirewallRule> {
let httpRequest: WebRequest = {
method: 'GET',
uri: this._restClient.getRequestUri(`/${this._resource!.id}/firewallRules/${ruleName}`, {}, [], '2017-12-01')
};
try {
let httpResponse = await this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode !== 200) {
throw ToError(httpResponse);
}
return httpResponse.body as FirewallRule;
}
catch(error) {
if (error instanceof AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
}
private async _getLongRunningOperationResult(response: WebResponse): Promise<WebResponse> {
let timeoutInMinutes = 2;
let timeout = new Date().getTime() + timeoutInMinutes * 60 * 1000;
let request = {
method: 'GET',
uri: response.headers['azure-asyncoperation'] || response.headers['location']
} as WebRequest;
if (!request.uri) {
throw new Error('Unable to find the Azure-Async operation polling URI.');
}
while (true) {
response = await this._restClient.beginRequest(request);
if (response.statusCode === 202 || (response.body && (response.body.status == 'Accepted' || response.body.status == 'Running' || response.body.status == 'InProgress'))) {
if (timeout < new Date().getTime()) {
throw new Error(`Async polling request timed out. URI: ${request.uri}`);
}
let retryAfterInterval = response.headers['retry-after'] && parseInt(response.headers['retry-after']) || 15;
await this._sleep(retryAfterInterval);
}
else {
break;
}
}
return response;
}
private async _populateMySqlServerData(serverName: string) {
// trim the cloud hostname suffix from servername
serverName = serverName.split('.')[0];
let httpRequest: WebRequest = {
method: 'GET',
uri: this._restClient.getRequestUri('//subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/servers', {}, [], '2017-12-01')
}
core.debug(`Get MySQL server '${serverName}' details`);
try {
let httpResponse = await this._restClient.beginRequest(httpRequest);
if (httpResponse.statusCode !== 200) {
throw ToError(httpResponse);
}
let sqlServers = httpResponse.body && httpResponse.body.value as AzureMySqlServer[];
if (sqlServers && sqlServers.length > 0) {
this._resource = sqlServers.filter((sqlResource) => sqlResource.name === serverName)[0];
if (!this._resource) {
throw new Error(`Unable to get details of MySQL server ${serverName}. MySql server '${serverName}' was not found in the subscription.`);
}
core.debug(JSON.stringify(this._resource));
}
else {
throw new Error(`Unable to get details of MySQL server ${serverName}. No MySQL servers were found in the subscription.`);
}
}
catch(error) {
if (error instanceof AzureError) {
throw new Error(JSON.stringify(error));
}
throw error;
}
}
private _sleep(sleepDurationInSeconds: number): Promise<any> {
return new Promise((resolve) => {
setTimeout(resolve, sleepDurationInSeconds * 1000);
});
}
private _resource?: AzureMySqlServer;
private _restClient: AzureRestClient;
}

73
src/FirewallManager.ts Normal file
Просмотреть файл

@ -0,0 +1,73 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import AzureMySqlActionHelper from "./AzureMySqlActionHelper";
import AzureMySqlResourceManager from './AzureMySqlResourceManager';
const ipv4MatchPattern = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
export default class FirewallManager {
constructor(azureMySqlResourceManager: AzureMySqlResourceManager) {
this._resourceManager = azureMySqlResourceManager;
}
public async addFirewallRule(serverName: string, connectionString: any): Promise<void> {
let ipAddress = await this._detectIPAddress(serverName, connectionString);
if (!ipAddress) {
core.debug(`Client has access to MySql server. Skip adding firewall exception.`);
return;
}
console.log(`Client does not have access to MySql server. Adding firewall exception for client's IP address.`)
this._firewallRule = await this._resourceManager.addFirewallRule(ipAddress, ipAddress);
core.debug(JSON.stringify(this._firewallRule));
console.log(`Successfully added firewall rule ${this._firewallRule.name}.`);
}
public async removeFirewallRule(): Promise<void> {
if (this._firewallRule) {
console.log(`Removing firewall rule '${this._firewallRule.name}'.`);
await this._resourceManager.removeFirewallRule(this._firewallRule);
console.log('Successfully removed firewall rule.');
}
else {
core.debug('No firewall exception was added.')
}
}
private async _detectIPAddress(serverName: string, connectionString: any): Promise<string> {
let mySqlClientPath = await AzureMySqlActionHelper.getMySqlClientPath();
let ipAddress = '';
let mySqlError = '';
try {
core.debug(`Validating if client has access to MySql Server '${serverName}'.`);
core.debug(`"${mySqlClientPath}" -h ${serverName} -u "${connectionString.userId}" -e "show databases"`);
await exec.exec(`"${mySqlClientPath}" -h ${serverName} -u "${connectionString.userId}" --password="${connectionString.password}" -e "show databases"`, [], {
silent: true,
listeners: {
stderr: (data: Buffer) => mySqlError += data.toString()
}
});
}
catch (error) {
core.debug(mySqlError);
let ipAddresses = mySqlError.match(ipv4MatchPattern);
if (!!ipAddresses) {
ipAddress = ipAddresses[0];
}
else {
throw new Error(`Failed to add firewall rule. Unable to detect client IP Address. ${mySqlError} ${error}`)
}
}
//ipAddress will be an empty string if client has access to SQL server
return ipAddress;
}
private _firewallRule: any; // assign proper type
private _resourceManager: AzureMySqlResourceManager;
}

Просмотреть файл

@ -0,0 +1,121 @@
/**
* The basic format of a connection string includes a series of keyword/value pairs separated by semicolons.
* The equal sign (=) connects each keyword and its value. (Ex: Key1=Val1;Key2=Val2)
*
* Following rules are to be followed while passing special characters in values:
1. To include values that contain a semicolon, single-quote character, or double-quote character, the value must be enclosed in double quotation marks.
2. If the value contains both a semicolon and a double-quote character, the value can be enclosed in single quotation marks.
3. The single quotation mark is also useful if the value starts with a double-quote character. Conversely, the double quotation mark can be used if the value starts with a single quotation mark.
4. If the value contains both single-quote and double-quote characters, the quotation mark character used to enclose the value must be doubled every time it occurs within the value.
Regex used by the parser(connectionStringParserRegex) to parse the VALUE:
('[^']*(''[^']*)*') -> value enclosed with single quotes and has consecutive single quotes
|("[^"]*(""[^"]*)*") -> value enclosed with double quotes and has consecutive double quotes
|((?!['"])[^;]*)) -> value does not start with quotes does not contain any special character. Here we do a positive lookahead to ensure that the value doesn't start with quotes which should have been handled in previous cases
Regex used to validate the entire connection string:
A connection string is considered valid if it is a series of key/value pairs separated by semicolons. Each key/value pair must satisy the connectionStringParserRegex to ensure it is a valid key/value pair.
^[;\s]*{KeyValueRegex}(;[;\s]*{KeyValueRegex})*[;\s]*$
where KeyValueRegex = ([\w\s]+=(?:('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*))))
*/
import * as core from '@actions/core';
const connectionStringParserRegex = /(?<key>[\w\s]+)=(?<val>('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*))/g
const connectionStringTester = /^[;\s]*([\w\s]+=(?:('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*)))(;[;\s]*([\w\s]+=(?:('[^']*(''[^']*)*')|("[^"]*(""[^"]*)*")|((?!['"])[^;]*))))*[;\s]*$/
export interface MySqlConnectionString {
server: string;
userId: string;
password: string;
database: string;
}
export default class MySqlConnectionStringBuilder {
constructor(connectionString: string) {
this._connectionString = connectionString;
this._validateConnectionString();
this._parsedConnectionString = this._parseConnectionString();
}
public get connectionString(): string {
return this._connectionString;
}
public get userId(): string {
return this._parsedConnectionString.userId;
}
public get password(): string {
return this._parsedConnectionString.password;
}
public get database(): string {
return this._parsedConnectionString.database;
}
private _validateConnectionString() {
if (!connectionStringTester.test(this._connectionString)) {
throw new Error('Invalid connection string. A valid connection string is a series of keyword/value pairs separated by semi-colons. If there are any special characters like quotes, semi-colons in the keyword value, enclose the value within quotes. Refer this link for more info on conneciton string https://aka.ms/sqlconnectionstring');
}
}
private _parseConnectionString(): MySqlConnectionString {
let result = this._connectionString.matchAll(connectionStringParserRegex);
let parsedConnectionString: MySqlConnectionString = {} as any;
for(let match of result) {
if (match.groups) {
let key = match.groups.key.trim();
let val = match.groups.val.trim();
/**
* If the first character of val is a single/double quote and there are two consecutive single/double quotes in between,
* convert the consecutive single/double quote characters into one single/double quote character respectively (Point no. 4 above)
*/
if (val[0] === "'") {
val = val.slice(1, -1);
val = val.replace(/''/g, "'");
}
else if (val[0] === '"') {
val = val.slice(1, -1);
val = val.replace(/""/g, '"');
}
switch(key.toLowerCase()) {
case 'user id':
case 'uid': {
parsedConnectionString.userId = val;
break;
}
case 'password':
case 'pwd': {
parsedConnectionString.password = val;
// masking the connection string password to prevent logging to console
core.setSecret(val);
break;
}
case 'database': {
parsedConnectionString.database = val;
break;
}
case 'server': {
parsedConnectionString.server = val;
break;
}
}
}
}
if (!parsedConnectionString.userId || !parsedConnectionString.password || !parsedConnectionString.database) {
throw new Error(`Missing required keys in connection string. Please ensure that the keys 'User Id', 'Password', 'Database' are provided in the connection string.`);
}
return parsedConnectionString;
}
private _connectionString: string = '';
private _parsedConnectionString: MySqlConnectionString;
}

66
src/main.ts Normal file
Просмотреть файл

@ -0,0 +1,66 @@
import * as core from '@actions/core';
import * as crypto from 'crypto';
import * as path from 'path';
import { AuthorizerFactory } from 'azure-actions-webclient/AuthorizerFactory';
import AzureMySqlActionHelper from './AzureMySqlActionHelper';
import AzureMySqlAction, { IActionInputs } from "./AzureMySqlAction";
import FirewallManager from './FirewallManager';
import AzureMySqlResourceManager from './AzureMySqlResourceManager';
import MySqlConnectionStringBuilder from './MySqlConnectionStringBuilder';
let userAgentPrefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USER_AGENT}` : "";
export async function run() {
let firewallManager;
try {
// Set user agent variable
let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex');
let actionName = 'AzureMySqlAction';
let userAgentString = (!!userAgentPrefix ? `${userAgentPrefix}+` : '') + `GITHUBACTIONS_${actionName}_${usrAgentRepo}`;
core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString);
let inputs = getInputs();
let azureMySqlAction = new AzureMySqlAction(inputs);
let azureResourceAuthorizer = await AuthorizerFactory.getAuthorizer();
let azureMySqlResourceManager = await AzureMySqlResourceManager.getResourceManager(inputs.serverName, azureResourceAuthorizer);
firewallManager = new FirewallManager(azureMySqlResourceManager);
await firewallManager.addFirewallRule(inputs.serverName, inputs.connectionString);
await azureMySqlAction.execute();
}
catch(error) {
core.setFailed(error.message);
}
finally {
if (firewallManager) {
await firewallManager.removeFirewallRule();
}
// Reset AZURE_HTTP_USER_AGENT
core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentPrefix);
}
}
function getInputs(): IActionInputs {
let serverName = core.getInput('server-name', { required: true });
let connectionString = core.getInput('connection-string', { required: true });
let connectionStringBuilder = new MySqlConnectionStringBuilder(connectionString);
let sqlFile = AzureMySqlActionHelper.resolveFilePath(core.getInput('sql-file', { required: true }));
if (path.extname(sqlFile).toLowerCase() !== '.sql') {
throw new Error(`Invalid sql file path provided as input ${sqlFile}`);
}
let additionalArguments = core.getInput('arguments');
return {
serverName: serverName,
connectionString: connectionStringBuilder,
sqlFile: sqlFile,
additionalArguments: additionalArguments
};
}
run();

64
tsconfig.json Normal file
Просмотреть файл

@ -0,0 +1,64 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"lib": ["es2020.string"], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
},
"exclude": ["node_modules", "**/*.test.ts"]
}