ci-auto-deploy
Version:
Automatic Deployment for Continuous Delivery Pipelines
676 lines (554 loc) • 23.2 kB
JavaScript
// ---------- CI-Auto-Deploy Cloud Deployment Program using eb client tool -------------------------
//
// # Author : Bircan Bilici
// # Github: https://github.com/brcnblc
// # Licence : MIT
//
// --------------------------------------------------------------------------------------------------
;
const { spawnSync } = require("child_process");
const fs = require("fs");
const { git, nested } = require('./library');
const deepCopyPro = require('./deepCopyPro')
const _ = require('lodash');
const md5 = require('blueimp-md5');
const { evaluateYaml, parseYmlFile } = require("./yaml_script")
const {runSpawn, runSpawnSync, copyFile, disableLine, sleep, passwordPrompt, startingPrompt,
camelCase, dumpYmlFile, dateSuffix} = require('./helper')
// Write last deploy parameters into yaml file
function writeLastDeployedFile(file, field, value){
let content = {}
content[field]=value;
dumpYmlFile(file, content)
}
// Write last ran configuration
function writeLastRun(kwArgs){
let lastRunFile;
const exceptionList = ['cloud','path']
if (kwArgs && kwArgs.lastRunFile && kwArgs.deployPath ){
lastRunFile = kwArgs.deployPath + '/' + kwArgs.lastRunFile
} else {
lastRunFile = kwArgs.lastRunFile;
}
const copyOfkwArgs = deepCopyPro(kwArgs, {omitKeys:exceptionList})
writeLastDeployedFile(lastRunFile, 'kwArgs', copyOfkwArgs)
}
function evaluateSubConfig(kwArgs, lastRunConfig, configKey, forceDefaults){
if (kwArgs[configKey].includes('last')) {
// If it includes || and forceDefaults = false
const evalarg = kwArgs[configKey].split('||')
if (evalarg.length == 2 &! forceDefaults && lastRunConfig.errno == -2){
kwArgs[configKey] = evalarg[1].trim()
return
}
// If it does not include ||
if (lastRunConfig.errno == -2 || forceDefaults) {
const {cloudProvider, cloud} = kwArgs;
const cloudProviderDefaults = cloud && cloud[cloudProvider];
if (cloudProviderDefaults) {
const configKeyDefault = cloudProviderDefaults[configKey];
if (configKeyDefault){
kwArgs[configKey] = configKeyDefault
} else {
throw (`Please indicate ${configKey} explicitly or configure it in the defaults file.`)
}
} else {
throw (`Please indicate ${configKey} explicitly or create a cloud defaults file.`)
}
} else {
kwArgs[configKey] = lastRunConfig.kwArgs[configKey]
}
}
}
function evaluateLast(kwArgs, forceDefaults = false) {
// Read and apply last ran configuration
const lastRunConfig = parseYmlFile(kwArgs.lastRunFile)
// Evaluate last ran cloud provider
if (kwArgs.cloudProvider.includes('last')) {
if (lastRunConfig.errno == -2) {
const evalarg = kwArgs.cloudProvider.split('||')
if (evalarg.length == 1){
throw (`Please indicate cloud provider explicitly with --cloud-provider argument or define it under defaults section of
"${kwArgs.configFile}" file such as :
defaults:
cloudProvider: myDefaultProvider`)
} else {
kwArgs.cloudProvider = evalarg[1].trim()
}
} else {
kwArgs.cloudProvider = lastRunConfig.kwArgs.cloudProvider
}
}
const lastConfigKeys = ['application', 'environment', 'cloudService', 'commandLineTool', 'launchFile', 'deployConfigFile']
lastConfigKeys.forEach(item => evaluateSubConfig(kwArgs, lastRunConfig, item, forceDefaults))
}
function checkFiles(kwArgs, raiseError = false){
const fileList = [kwArgs.launchFile, kwArgs.deployConfigFile]
for (let i=0;i<fileList.length;i++){
if (!fs.existsSync(fileList[i])){
if (raiseError){
throw(`File ${fileList[i]} does not exist`)
}
return false
}
}
return true;
}
function printStartingParameters(kwArgs){
console.log(`
Deployment will start with following parameters:
Cloud Provider: ${kwArgs.cloudProvider}
Cloud Service: ${kwArgs.cloudService}
Command Line Tool: ${kwArgs.commandLineTool}
Application: ${kwArgs.application}
Environment: ${kwArgs.environment}
`)
}
async function askPassword(kwArgs, appConfiguration, environment){
const passwordFileContent = parseYmlFile(kwArgs.passwordFile)
if ((appConfiguration.prompt == 'password' || environment == 'production')){
if (passwordFileContent.errno == -2){throw(`Error: Password file ${kwArgs.passwordFile} not found`)}
const passwords = passwordFileContent.passwords
const environmentPassword = passwords && passwords[environment]
if (!environmentPassword) {throw(`Password definition for ${environment} environment not found in ${kwArgs.passwordFile}`)}
let passOk = false;
if ((!kwArgs.passwords || kwArgs.passwords == null) && (!kwArgs.passwordHashs || kwArgs.passwordHashs == null)){
passOk = await passwordPrompt(environment, environmentPassword, 3);
if (!passOk){throw('Wrong Password in user entry.')}
} else if (kwArgs.passwords != null){
if (!kwArgs.passwords[environment]){throw(`--passwords argument does not contain definition for ${environment} environment.`)}
passOk = (md5(kwArgs.passwords[environment]) == environmentPassword)
if (!passOk){throw(`Wrong Password in --passwords argument.`)}
} else if (kwArgs.passwordHashs != null){
if (!kwArgs.passwordHashs[environment]){throw(`--passwordHashs argument does not contain definition for ${environment} environment.`)}
passOk = (kwArgs.passwordHashs[environment] == environmentPassword);
if (!passOk){throw(`Wrong Password in --password-hashs argument.`)}
}
} else if (appConfiguration.prompt == 'prompt'){
const userInput = await startingPrompt('y')
if (!userInput){throw ('Process aborted on user input.')}
}
}
function checkTokenFile(kwArgs, appConfiguration, environment){
if (kwArgs.copySecrets){
const tokenFile = appConfiguration.tokenFile
if (!tokenFile){throw(`Token file definition for ${environment} could not be found.`)}
const tokenFileContent = parseYmlFile(tokenFile)
if (tokenFileContent.errno == -2){throw(`Token file ${tokenFile} does not exist.`)}
}
}
// ---- INIT DEPLOY ------
async function initDeploy(kwArgs){
let exitCode;
// Evaluate last ran configuration
evaluateLast(kwArgs);
let filesExist = checkFiles(kwArgs, false)
if (!filesExist){
evaluateLast(kwArgs, true)
}
checkFiles(kwArgs, true)
const configuration = evaluateYaml (kwArgs.deployConfigFile, {args:kwArgs})
let {cloudProvider, cloudService, commandLineTool, application, environment, appConfig} = kwArgs;
const mainConfigName = camelCase(`${cloudProvider}_${cloudService}_${commandLineTool}`,'_');
const mainConfig = configuration.cloudProvider[cloudProvider].platform[mainConfigName]
if (!mainConfig){
throw (`Configuration for ${mainConfigName} not found in ${kwArgs.deployConfigFile}`)
}
const environmentSection = mainConfig.environment
if (!environmentSection){
throw (`environment section not found in ${kwArgs.deployConfigFile}`)
}
const environmentConfig = environmentSection[environment]
if (!environmentConfig){
throw (`Configuration for ${environment} environment not found in ${kwArgs.deployConfigFile}`)
}
const envGroups = environmentConfig.group
_.merge(kwArgs, {input: {cloudConfig: configuration}})
// Batch Processiong
if (kwArgs.application in envGroups){
const mainApplicationName = kwArgs.application
// Print starting args
printStartingParameters(kwArgs)
// ask password
await askPassword(kwArgs, environmentConfig.defaults, environment)
// Check if token file exists if copy-secrets is true
checkTokenFile(kwArgs, environmentConfig.defaults, environment)
const items = envGroups[kwArgs.application]
console.log (`Process started for ${items.length} application${items.length > 1 ? 's' : ''}
in ${environment} environment.`)
let cnt = 0, scnt=0, error = false;
// Loop through items
for (let appName of items){
cnt++;
kwArgs.application = appName;
console.log(`\nStep ${cnt} of ${items.length}`)
console.log(`----- Deploying "${appName}" into "${environment}" environment -----\n`)
try {
const appConfigPath = ['application', appName, 'configuration', appConfig]
const appConfiguration = _.get(environmentConfig, appConfigPath);
if (!appConfiguration){
throw (`${application} ${appConfig} configuration for ${environment} environment not found !`);
}
// Deploy
_.merge(kwArgs, appConfiguration)
_.merge(kwArgs, {input: {appConfig: appConfiguration}})
exitCode = await deploy(kwArgs, appConfiguration);
scnt++;
} catch (err) {
error = true;
if (kwArgs.debug && err.stack){
console.error(err.stack)
} else {
console.error(err)
}
}
console.log(`\n----- End of deployment ${appName} ${appConfig} into ${environment} environment -----\n`)
}
//End of batch deployment.
console.log(`${scnt} items deployed.\n`);
if (error){
console.error(`\n${cnt - scnt} items could not be deployed.\n`);
throw -1
}
// Write to last run file if process is succesfull
if (exitCode == 0){
kwArgs.application = mainApplicationName
writeLastRun(kwArgs)
}
} else {
// Extract configuration
const appConfigPath = ['application', application, 'configuration', appConfig]
const appConfiguration = _.get(environmentConfig, appConfigPath);
// Check if configuration exists
if (!appConfiguration){
throw (`${application} ${appConfig} configuration for ${environment} environment not found !`);
}
// Print starting args
printStartingParameters(kwArgs)
// ask password
await askPassword(kwArgs, appConfiguration, environment)
// Check if token file exists if copy-secrets is true
checkTokenFile(kwArgs, appConfiguration, environment)
// Merge appConfiguration into kwArgs
_.merge(kwArgs, appConfiguration)
_.merge(kwArgs, {input: {appConfig: appConfiguration}})
// Execute eb command
console.log(`\n----- Deploying ${application} ${appConfig} configuration into ${environment} environment -----\n`)
exitCode = await deploy(kwArgs, appConfiguration)
console.log(`\n----- End of deployment ${application} ${appConfig} into ${environment} environment -----\n`)
// Write to last run file if process is succesfull
if (exitCode == 0){
writeLastRun(kwArgs)
}
}
return exitCode;
}
// DEPLOY Main Function
async function deploy(kwArgs, appConfiguration) {
let exitCode;
let {cloudProvider, cloudService, commandLineTool, application, environment, appConfig} = kwArgs;
//Read last run config
let lastRunFileContents = parseYmlFile(kwArgs.lastRunFile) || {};
if (lastRunFileContents.kwArgs){
if (!cloudProvider){cloudProvider = lastRunFileContents.kwArgs.cloudProvider}
if (application == 'last'){application = lastRunFileContents.kwArgs.application}
if (!environment){environment = lastRunFileContents.kwArgs.environment}
}
//Read aws ebconfig yml file
let ebConfigContents = parseYmlFile('.elasticbeanstalk/config.yml', 'utf8')
if (ebConfigContents && application == 'last'){
application = ebConfigContents.global.application_name}
//Call platform specific deploy function
switch (`${cloudProvider},${cloudService},${commandLineTool}`){
case 'aws,elasticbeanstalk,eb':
await deployAwsElasticBeansTalk(kwArgs, appConfiguration)
.then(code => {exitCode = code})
.catch(err => {
if (!kwArgs.debug){
console.error('Error :', err); throw (-1)
} else {
console.error(err.stack || err)
throw (-1)
}
})
break;
default:
console.error ('Deployment Function Does not exist !');
throw (-1);
}
return exitCode;
}
// Evaluate Cname
function evaluateCname(kwArgs, appConfiguration, environmentName){
let cname;
const {application, environment} = kwArgs;
if (appConfiguration.activeCname == environmentName &! kwArgs.cname &! appConfiguration.cname){
cname = appConfiguration.activeCname;
} else if (kwArgs.cname){
cname = `${application}-${environment}-${kwArgs.cname}`
} else if (appConfiguration.cname) {
cname = `${application}-${environment}-${appConfiguration.cname}`
} else if (kwArgs.envSuffix){
cname = `${application}-${environment}-${kwArgs.envSuffix}`
} else {
cname = `${application}-${environment}`
}
return cname
}
// Evaluate and override launch configuration
function evalLaunch(kwArgs, createParameters, launch){
if (launch){
let dict = evaluateYaml(kwArgs.launchFile);
const launchKeys = [];
nested(dict, (cb) => {
if (cb.level == 1 && launchKeys.indexOf(cb.item) == -1){launchKeys.push(cb.item)}
});
let dictValue = dict[launch];
if (!dictValue){throw (`Launch configuration ${launch} not found!`)}
launchKeys.forEach(item=>{
delete(createParameters[item])
})
_.merge(createParameters, dictValue)
}
}
// Create env file from multi environment config file
function createEnvFile(kwArgs, env){
const file = kwArgs.envFile
let data = "";
data += "# CI-AUTO-DEPLOY" + "\n"
data += "# Automatically created from multi-config environment file\n"
data += "# On " + new Date + "\n"
data += "# " + "-".repeat(48) + "\n"
data += "# Cloud Provider : " + kwArgs.cloudProvider + "\n"
data += "# Cloud Service : " + kwArgs.cloudService + "\n"
data += "# Environment : " + kwArgs.environment + "\n"
data += "# Application : " + kwArgs.application + "\n"
data += "# Configuration : " + kwArgs.appConfig + "\n"
data += "# " + "-".repeat(48) + "\n"
data += "\n"
if (env){
for (let [key, value] of Object.entries(env)){
if (!Array.isArray(value)){
data += key + "=" + value + "\n"
} else {
data += key + "="
value.forEach((item,index)=>{
data += item
if (index != value.length - 1){
data += ","
}
})
data += "\n"
}
}
}
fs.writeFileSync(file, data)
return data
}
// AWS Elastic Beans Talk Deployement by eb client tool
async function deployAwsElasticBeansTalk (kwArgs, appConfiguration) {
//_.merge(kwArgs, appConfiguration)
const {environmentFile, disableLines, ebignoreContent, accessProfile} = appConfiguration;
const {application, environment, region, launch} = kwArgs;
let createParameters = appConfiguration.create
const environmentName = `${application}-${environment}` + (kwArgs.envSuffix ? `-${kwArgs.envSuffix}` : '')
// Evaluate cname
const cname = evaluateCname(kwArgs, appConfiguration, environmentName);
const isActiveEnvironment = (cname == appConfiguration.activeCname);
const cloudPath = kwArgs.cloud[kwArgs.cloudProvider].path
// Evaluate Launch
evalLaunch(kwArgs, createParameters, launch)
// Create environment file
createEnvFile(kwArgs, appConfiguration.env)
console.log('Environment file created.')
// Start Job
try {
let result, exitCode, ebArgs;
// init eb
let ebCmd = `eb init ${application} --region ${region} --platform Node.js --keyname aws`
if (accessProfile){ebCmd += ' --profile ' + accessProfile}
console.log(`\nInitializing elasticbeanstalk with ${ebCmd}`)
result = runSpawnSync(ebCmd, null, null, kwArgs.simulate)
console.log(result)
// Copy .ebextensions
result = runSpawnSync('rm -r .ebextensions', true)
result = runSpawnSync('mkdir .ebextensions',true)
result = runSpawnSync(`cp ${cloudPath}/.ebextensions/common/* .ebextensions/`,true)
result = runSpawnSync(`cp ${cloudPath}/.ebextensions/${environment}/* .ebextensions/`,true)
result = runSpawnSync(`cp ${cloudPath}/.ebextensions/${application}/* .ebextensions/`,true)
if (kwArgs.buildRun){
console.log(`Building application.`)
exitCode = await runSpawn('npm', ['run', kwArgs.buildScript], false)
if (exitCode != 0){throw(exitCode)}
}
if (!kwArgs.buildDeploy){
// Copy .gitignore file onto .ebignore file
copyFile('.gitignore', '.ebignore');
// comment lines in ignore file
disableLines.forEach(element => {
disableLine(element, '.ebignore', '#')
});
} else {
fs.writeFileSync('.ebignore', ebignoreContent.join('\n'))
}
// Check Status of environment
const status = {}
console.log('\nChecking status of ' + environmentName + ' ...')
ebCmd = `eb status ${environmentName}`
if (accessProfile){ebCmd += ' --profile ' + accessProfile}
result = runSpawnSync(ebCmd, true)
const statRaw = result.split('\n')
for (let i=1; i< statRaw.length - 1 ; i++){
const row = statRaw[i].trim().split(': ')
if (row[0] && row[1]){
status[row[0].trim()] = row[1].trim()
}
}
if (!result.includes('NotFoundError')){ console.log(result);}
// Check status to be ready to continue.
if (status.Status && status.Status != 'Ready'){
throw (`Environment "${environmentName}" is in "${status.Status}" state. It should be in "Ready" state in order to update.`)
}
// Get Other parameters
let {descriptionText, existingVersion, deployStaged,
forceUpdate, revisionPrefix, revisionNumber, versionSuffix} = kwArgs;
// Get Version Label
let version = kwArgs.versionLabel;
if (version == 'describe'){
version = git(`describe --tags --match "${kwArgs.versionPrefix}*.*.*"`).replace('\n','')
} else if (version == 'package') {
const packageFileString = fs.readFileSync(kwArgs.packageFile, 'utf8');
const packageFileContent = JSON.parse(packageFileString);
version = kwArgs.versionPrefix + packageFileContent['version'];
}
// Add Version Suffix
if (versionSuffix){
version += '-' + versionSuffix
}
// Check deployed version
const deployedVersion = status['Deployed Version'];
const deployedVersionLastDigit = deployedVersion && status['Deployed Version'].split('-').pop()
if (revisionNumber != 'none'){
if (revisionNumber != 'auto') {
version += `-${revisionPrefix}${revisionNumber}`
} else if (deployedVersion && deployedVersion.includes(version)){
if (version == deployedVersion){
version += `-${revisionPrefix}1`
} else {
const deployedRevNum = parseInt(deployedVersionLastDigit.substr(revisionPrefix.length))
version += '-' + revisionPrefix + (deployedRevNum + 1).toString()
}
}
}
// Create environment if it does not exist and force-create argument is passed
if (result.search('ERROR: NotFoundError') > -1){
if (kwArgs.forceCreate){
console.log(`\nEnvironment ${environmentName} not found. Creating ${environmentName} environment. This will take a few minutes.\n`)
if (!createParameters.cname) {createParameters['cname'] = cname};
let createArgs = ['create', environmentName, "--nohang", "--process"];
if (accessProfile){
createArgs.push('--profile')
createArgs.push(accessProfile)
}
for (let [key, value] of Object.entries(createParameters).sort()) {
createArgs.push(`--${key}`);
if (typeof value != 'boolean') {createArgs.push(`"${value}"`)};
}
exitCode = await runSpawn('eb', createArgs, kwArgs.simulate)
if (exitCode != 0){ throw ('Error occurred while creating environment.')}
} else {
throw result
}
return exitCode
}
//Check environment Again
ebCmd = `eb use ${environmentName}`
if (accessProfile){ebCmd += ' --profile ' + accessProfile}
result = runSpawnSync(ebCmd, true)
if (result.search('ERROR: NotFoundError') > -1){
throw `Environment ${environmentName} not found.`
}
// Deploy
console.log('Deploying Application. \n')
ebArgs = ['deploy', environmentName];
if (!existingVersion) {
ebArgs.push(...['--label', `"${version}"`]);
}
if (descriptionText){
ebArgs.push(...['--message', `"${descriptionText}"`])
}
if (existingVersion){
ebArgs.push(...['--version', `"${existingVersion}"`])
}
if (deployStaged){
ebArgs.push('--staged')
}
if (forceUpdate){
ebCmd = `eb appversion --delete ${version}`
if (accessProfile){ebCmd += ' --profile ' + accessProfile}
result = runSpawnSync(ebCmd, true, 'y\n')
if (result.includes('deleted successfully')){
console.log('Old version deleted succesfully')
} else if (!result.includes('does not have Application Version')){
console.error(result)
}
}
ebArgs.push('--process')
ebArgs.push('--nohang')
if (accessProfile){
ebArgs.push('--profile')
ebArgs.push(accessProfile)
}
// Run command
exitCode = await runSpawn('eb', ebArgs, kwArgs.simulate)
.catch(async function (err) {
if (err.includes('WARNING: Deploying a previously deployed commit')){
result = runSpawnSync('eb abort')
console.error('Warning : Version label already exists, Aborting and adding timestamp suffix to version label name.')
let cnt=0, retry = 5, done=false, ecode=null;
let interval = setInterval(function intervalFunction(){
// Wait for Ready State
cnt++;
if (cnt > retry) {
clearInterval(interval);
throw(`Number of retries exceeded ${retry} while waiting for Ready state.`)
}
ebCmd = `eb status ${environmentName}`
if (accessProfile){ebCmd += ' --profile ' + accessProfile}
const newStatus = runSpawnSync(ebCmd)
const statusStart = newStatus.indexOf('Status');
const statusEnd = newStatus.indexOf('\n',statusStart);
const statusStr = newStatus.substring(statusStart, statusEnd)
console.log('Retry ', cnt, ' ', statusStr);
if (newStatus.includes('Status: Ready')){
clearInterval(interval);
const findInd = ebArgs.indexOf('--label');
const oldVersion = ebArgs[findInd + 1].replace(/\"/g,'');
const newVersion = oldVersion + '-' + dateSuffix('_')
ebArgs[findInd + 1] = newVersion
console.log(`Retrying to deploy application with time stamp as ${newVersion}`)
// run eb command again
runSpawn('eb', ebArgs, kwArgs.simulate)
.then(code => {ecode = code;done=true;})
.catch(err => {ecode = err;done=true})
}
}, 5000);
while(!done){
await sleep(1000)
}
if (ecode != 0){
throw ecode
} else {
return ecode
}
} else {
throw err
}
})
return exitCode
}
catch (err) {
throw err;
}
}
module.exports = initDeploy;