tools/scheduled-deployments/functions/deploy.js (184 lines of code) (raw):
/**
* @fileoverview Helper functions that implement the Deployment stage of
* Scheduled Deployments.
*/
'use strict';
const async = require('async');
const cronParser = require('cron-parser');
const google = require('googleapis');
const uuid = require('uuid/v1');
const yamljs = require('yamljs');
const constants = require('./constants');
const datastore = require('./datastore');
const logging = require('./logging');
const schedule = require('./schedule');
const deploymentManager = google.deploymentmanager('v2');
/**
* Deploy specified configuration.
*
* @param {!Object} deploymentDetails - Details from Datastore entity.
* @param {string} deploymentName
* @param {number} parentId - Entity ID of the Scheduled Deployment parent.
* @param {string=} prefix - If provided, prepends string to deployment name.
*/
exports
.deployConfig = (deploymentDetails, deploymentName, parentId, prefix = '') => {
const requiredFields = ['config', 'description'];
const missingFields =
datastore.findMissingFields(requiredFields, deploymentDetails);
if (missingFields.length > 0) {
throw new Error(
'Datastore trigger entity missing the following fields: ' +
missingFields.join(','));
}
exports.authorize()
.then((authClient) => {
const request = {
project: constants.PROJECT_ID,
resource: {
'name': prefix + deploymentName,
'description': deploymentDetails.description,
'target': {
'config':
{'content': yamljs.stringify(deploymentDetails.config, 2)}
}
},
auth: authClient
};
// not all configurations will have imports
if ('importName' in deploymentDetails &&
'importContent' in deploymentDetails) {
request.resource.target.imports = [{
'name': deploymentDetails.importName,
'content': deploymentDetails.importContent
}];
} else if (
'importName' in deploymentDetails ||
'importContent' in deploymentDetails) {
throw new Error(
`Trigger entity from Datastore missing one of 'importName' ` +
`or 'importContent'.`);
}
deploymentManager.deployments.insert(request, function(err, response) {
logging.logOperation(parentId, deploymentName, err, response, prefix);
});
})
.catch((err) => {
logging.logError(
`Unable to deploy Scheduled Deployment '${deploymentName}'.`, err);
});
};
/**
* Delete specified deployment.
*
* @param {string} deploymentName - Name of deployment to delete.
* @param {number} parentId - Entity ID of the Scheduled Deployment parent.
* @param {string=} prefix - If provided, prepends string to deployment name.
*/
exports.deleteDeployment = (deploymentName, parentId, prefix = '') => {
exports.authorize()
.then((authClient) => {
const request = {
project: constants.PROJECT_ID,
deployment: prefix + deploymentName,
auth: authClient
};
deploymentManager.deployments.delete(request, function(err, response) {
logging.logOperation(parentId, deploymentName, err, response, prefix);
});
})
.catch((err) => {
logging.logError(
`Unable to delete Scheduled Deployment '${deploymentName}'. ` +
'Deployment Manager authentication failed.\n',
err);
});
};
/**
* Retrieve Trigger entities in Datastore linked to the provided Scheduled
* Deployment entity and send those Triggers to be checked.
*
* @param {!Object} scheduledDeployment - Entity from Datastore.
* @returns {Promise<!Array<!Object>>} Trigger entities corresponding to the
* provided Scheduled Deployment.
*/
exports.getTriggers = (scheduledDeployment) => {
const deploymentPath = datastore.getEntityKey(scheduledDeployment).path;
return new Promise(function(resolve, reject) {
datastore.getChildrenOfEntity(deploymentPath, constants.TRIGGER_KIND)
.then((results) => {
const triggers = results[0];
resolve(triggers);
})
.catch((err) => {
reject(err);
});
});
};
/**
* Iterate through Trigger entries from Datastore and make an array of all
* active triggers.
*
* @param {!Array<!Object>} triggers - Trigger entities from Datastore.
* @param {!Object} parent - Scheduled Deployment entity.
* @returns {Promise<!Array<!Object>>} Promise resolves to an array of all
* active Trigger entities.
*/
exports.getActiveTriggers = (triggers, parent) => {
const deploymentName = parent.name;
const lastDeployed = parent.lastDeployed || 0;
return new Promise((resolve, reject) => {
const activeTriggers = [];
const currentTime = Date.now();
async.forEachOf(
triggers,
(trigger, _, callback) => {
schedule.isTriggerFormatValid(trigger, deploymentName)
.then(() => {
const crontab = trigger.time;
return exports.crontabWithinInterval(
currentTime, constants.CRON_INTERVAL_IN_MIN, crontab,
lastDeployed);
})
.then((timestamp) => {
if (timestamp > 0) {
activeTriggers.push({timestamp: timestamp, entity: trigger});
}
callback();
})
.catch((err) => {
return callback(err);
});
},
(err) => {
if (err) {
reject(err);
} else {
resolve(activeTriggers);
}
});
});
};
/**
* Select the active trigger with the latest timestamp and make a deployment
* or deletion as appropriate.
*
* @param {!Array<!Object>} activeTriggers - List of active triggers.
* @param {!Object} parent - Scheduled Deployment entity.
*/
exports.processActiveTriggers = (activeTriggers, parent) => {
const deploymentName = parent.name;
const parentId = parseInt(datastore.getEntityKey(parent).id);
if (activeTriggers.length > 0) {
const latestTrigger = activeTriggers.sort(exports.compareTriggers)[0];
if (latestTrigger.entity.action == 'CREATE_OR_UPDATE') {
exports.deployConfig(
latestTrigger.entity, deploymentName, parentId, constants.PREFIX);
} else if (latestTrigger.entity.action == 'DELETE') {
exports.deleteDeployment(deploymentName, parentId, constants.PREFIX);
} else {
loggging.logError(
`Unknown trigger action ${latestTrigger.entity.action}.`);
}
}
};
/**
* Comparison function to sort trigger entities. Priority goes first to later
* timestamps and second in reverse alphabetical order of name.
*
* @param {{timestamp: number, entity: {name: string}}} x - Trigger object.
* @param {{timestamp: number, entity: {name: string}}} y - Trigger object.
* @returns {number} -1 if x has priority
* 1 if y has priority
* 0 if neither has priority over the other
*/
exports.compareTriggers = (x, y) => {
if (x.timestamp > y.timestamp) {
return -1;
} else if (x.timestamp < y.timestamp) {
return 1;
} else if (x.entity.name > y.entity.name) {
return -1;
} else if (x.entity.name < y.entity.name) {
return 1;
} else {
return 0;
}
};
/**
* Determine whether the provided time falls within the current interval of
* the crontab schedule. If so, return the time scheduled for the deployment.
*
* A time is considered within the interval if it's within interval/2 minutes
* before or after the Cron trigger.
*
* @param {number} time - current time in UNIX epoch time (milliseconds)
* @param {number} interval - time between Cron timer events (minutes)
* @param {string} crontab - time of trigger, in crontab format
* @param {number} lastDeployed - last time this scheduled deployment was
* deployed, in UNIX epoch time (milliseconds)
* @returns {Promise<number>} time in UNIX epoch of the scheduled time if
* valid; 0 otherwise
*/
exports.crontabWithinInterval = (time, interval, crontab, lastDeployed) => {
return new Promise((resolve, reject) => {
const schedule = cronParser.parseExpression(crontab, {currentDate: time});
const prevScheduled = schedule.prev().getTime();
const nextScheduled = schedule.next().getTime();
if (nextScheduled <= (time + ((interval / 2) * 1000 * 60))) {
resolve(nextScheduled);
} else if (prevScheduled >= (time - ((interval / 2) * 1000 * 60))) {
resolve(prevScheduled);
} else if (lastDeployed < prevScheduled) {
// we missed the most recent deployment
resolve(prevScheduled);
} else {
resolve(0);
}
});
};
/**
* Authorize client to access Deployment Manager.
*
* @returns {Promise<AuthClient>} Authorization credential for use in calling
* Google APIs.
*/
exports.authorize = () => {
return new Promise((resolve, reject) => {
google.auth.getApplicationDefault((err, authClient) => {
if (err) {
reject(err);
}
if (authClient.createScopedRequired &&
authClient.createScopedRequired()) {
const scopes = ['https://www.googleapis.com/auth/cloud-platform'];
authClient = authClient.createScoped(scopes);
}
resolve(authClient);
});
});
};