diff options
author | Lucas, John (jl1315) <jflucas@research.att.com> | 2017-08-23 18:58:33 +0000 |
---|---|---|
committer | J. F. Lucas <jflucas@research.att.com> | 2017-08-23 19:05:41 +0000 |
commit | 3c1527544df835a831898edbd74a473f027bb855 (patch) | |
tree | 2e2cf0c7060b39aec4829dc866fcb2468fa16b77 /lib | |
parent | bacbd4c6349f9de9c597d309a6c23eced2058a15 (diff) |
deployment handler initial seed code
Change-Id: I0bfc86d17edead0114ea0012fb469014e978cd15
Issue-Id: DCAEGEN2-43
Signed-off-by: J. F. Lucas <jflucas@research.att.com>
Diffstat (limited to 'lib')
-rw-r--r-- | lib/auth.js | 65 | ||||
-rw-r--r-- | lib/cloudify.js | 312 | ||||
-rw-r--r-- | lib/config.js | 213 | ||||
-rw-r--r-- | lib/consul.js | 66 | ||||
-rw-r--r-- | lib/dcae-deployments.js | 234 | ||||
-rw-r--r-- | lib/deploy.js | 306 | ||||
-rw-r--r-- | lib/dispatcher-error.js | 53 | ||||
-rw-r--r-- | lib/info.js | 38 | ||||
-rw-r--r-- | lib/inventory.js | 169 | ||||
-rw-r--r-- | lib/logging.js | 266 | ||||
-rw-r--r-- | lib/middleware.js | 77 | ||||
-rw-r--r-- | lib/promise_request.js | 114 | ||||
-rw-r--r-- | lib/repeat.js | 54 | ||||
-rw-r--r-- | lib/utils.js | 39 |
14 files changed, 2006 insertions, 0 deletions
diff --git a/lib/auth.js b/lib/auth.js new file mode 100644 index 0000000..9ddd7b3 --- /dev/null +++ b/lib/auth.js @@ -0,0 +1,65 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +/* HTTP Basic Authentication */ + +"use strict"; + +/* Extract user name and password from the 'Authorization' header */ +const parseAuthHeader = function(authHeader){ + + var parsedHeader = {}; + + const authItems = authHeader.split(/\s+/); // Split on the white space between Basic and the base64 encoded user:password + + if (authItems[0].toLowerCase() === 'basic') { + if (authItems[1]) { + const authString = (new Buffer(authItems[1], 'base64')).toString(); + const userpass = authString.split(':'); + if (userpass.length > 1) { + parsedHeader = {user: userpass[0], password: userpass[1]}; + } + } + } + return parsedHeader; +}; + +/* Middleware function to check authentication */ +exports.checkAuth = function(req, res, next) { + const auth = process.mainModule.exports.config.auth; + if (auth) { + /* Authentication is configured */ + if (req.headers.authorization) { + const creds = parseAuthHeader(req.headers.authorization); + if (creds.user && creds.password && (creds.user in auth) && (auth[creds.user] === creds.password)) { + next(); + } + else { + var err = new Error('Authentication required'); + err.status = 403; + next(err); + } + } + else { + var errx = new Error ('Authentication required'); + errx.status = 403; + next(errx); + } + } + else { + next(); // Nothing to do, no authentication required + } +};
\ No newline at end of file diff --git a/lib/cloudify.js b/lib/cloudify.js new file mode 100644 index 0000000..150f1c4 --- /dev/null +++ b/lib/cloudify.js @@ -0,0 +1,312 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +/* Low-level routines for using the Cloudify Manager REST API */ + +"use strict"; + +const admzip = require('adm-zip'); + +const repeat = require('./repeat'); +const req = require('./promise_request'); +const doRequest = req.doRequest; + +var cfyAPI = null; +var cfyAuth = null; +var logger = null; + + +// Delay function--returns a promise that's resolved after 'dtime' +// milliseconds.` +var delay = function(dtime) { + return new Promise(function(resolve, reject) { + setTimeout(resolve, dtime); + }); +}; + +// Get current status of a workflow execution +// Function for getting execution info +const getExecutionStatus = function(executionId) { + var reqOptions = { + method : "GET", + uri : cfyAPI + "/executions/" + executionId + }; + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + return doRequest(reqOptions); +}; + +// Poll for the result of a workflow execution +var getWorkflowResult = function(execution_id) { + var finished = [ "terminated", "cancelled", "failed" ]; + var retryInterval = 15000; // Every 15 seconds + var maxTries = 240; // Up to an hour + + logger.debug(null, "Getting workflow result for execution id: " + execution_id); + + // Function for testing if workflow is finished + // Expects the result of getExecStatus + var checkStatus = function(res) { + logger.debug(null, "Checking result: " + JSON.stringify(res) + " ==> " + (res.json && res.json.status && finished.indexOf(res.json.status) < 0)); + return res.json && res.json.status && finished.indexOf(res.json.status) < 0; + }; + + // Create execution status checker function + var getExecStatus = function() { return getExecutionStatus(execution_id);}; + + return repeat.repeatWhile(getExecStatus, checkStatus, maxTries, retryInterval) + .then( + + /* Handle fulfilled promise from repeatWhile */ + function(res) { + + logger.debug(null, 'workflow result: ' + JSON.stringify(res)); + + /* Successful completion */ + if (res.json && res.json.status && res.json.status === 'terminated') { + return res; + } + + /* If we get here, we don't have a success and we're going to throw something */ + + var error = {}; + + /* We expect a JSON object with a status */ + if (res.json && res.json.status) { + + /* Failure -- we need to return something that looks like the CM API failures */ + if (res.json.status === 'failed') { + error.body = 'workflow failed: ' + execution_id + ' -- ' + (res.json.error ? JSON.stringify(res.json.error) : 'no error information'); + } + + /* Cancellation -- don't really expect this */ + else if (res.json.status === 'canceled' || res.json.status === 'cancelled') { + error.body = 'workflow canceled: ' + execution_id; + } + + /* Don't expect anything else -- but if we get it, it's not a success! */ + else { + error.body = 'workflow--unexpected status ' + res.json.status + ' for ' + execution_id; + } + } + + /* The body of the response from the API call to get execution status is not what we expect at all */ + else { + error.body = 'workflow--unexpected result body getting execution status from CM for ' + execution_id; + } + + throw error; + }, + + /* Handle rejection of promise from repeatWhile--don't use a catch because it would catch the error thrown above */ + function(err) { + /* repeatWhile could fail and we get here because: + * -- repeatWhile explicitly rejects the promise because it has exhausted the retries + * -- repeatWhile propagates a system error (e.g., network problem) trying to access the API + * -- repeatWhile propagates a rejected promise due to a bad HTTP response status + * These should all get normalized in deploy.js--so we just rethrow the error. + */ + + throw err; + + }); +}; + +//Initiate a workflow execution against a deployment +const initiateWorkflowExecution = function(dpid, workflow) { + // Set up the HTTP POST request + var reqOptions = { + method : "POST", + uri : cfyAPI + "/executions", + headers : { + "Content-Type" : "application/json", + "Accept" : "*/*" + } + }; + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + var body = { + deployment_id : dpid, + workflow_id : workflow + }; + + // Make the POST request + return doRequest(reqOptions, JSON.stringify(body)) + .then(function(result) { + logger.debug(null, "Result from POSTing workflow execution start: " + JSON.stringify(result)); + if (result.json && result.json.id) { + return {deploymentId: dpid, workflowType: workflow, executionId: result.json.id}; + } + else { + logger.debug(null,"Did not get expected JSON body from POST to start workflow"); + var err = new Error("POST to start workflow got success response but no body"); + err.status = err.code = 502; + } + }); +}; + +// Uploads a blueprint via the Cloudify API +exports.uploadBlueprint = function(bpid, blueprint) { + + // Cloudify API wants a gzipped tar of a directory, not the blueprint text + var zip = new admzip(); + zip.addFile('work/', new Buffer(0)); + zip.addFile('work/blueprint.yaml', new Buffer(blueprint, 'utf8')); + var src = (zip.toBuffer()); + + // Set up the HTTP PUT request + var reqOptions = { + method : "PUT", + uri : cfyAPI + "/blueprints/" + bpid, + headers : { + "Content-Type" : "application/octet-stream", + "Accept" : "*/*" + } + }; + + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + // Initiate PUT request and return the promise for a result + return doRequest(reqOptions, src); +}; + +// Creates a deployment from a blueprint +exports.createDeployment = function(dpid, bpid, inputs) { + + // Set up the HTTP PUT request + var reqOptions = { + method : "PUT", + uri : cfyAPI + "/deployments/" + dpid, + headers : { + "Content-Type" : "application/json", + "Accept" : "*/*" + } + }; + + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + var body = { + blueprint_id : bpid + }; + if (inputs) { + body.inputs = inputs; + } + + // Make the PUT request to create the deployment + return doRequest(reqOptions, JSON.stringify(body)); +}; + +// Initiate a workflow execution against a deployment +exports.initiateWorkflowExecution = initiateWorkflowExecution; + +// Get the status of a workflow execution +exports.getWorkflowExecutionStatus = getExecutionStatus; + +// Return a promise for the final result of a workflow execution +exports.getWorkflowResult = getWorkflowResult; + +// Executes a workflow against a deployment and returns a promise for final result +exports.executeWorkflow = function(dpid, workflow) { + + // Initiate the workflow + return initiateWorkflowExecution(dpid, workflow) + + // Wait for the result + .then (function(result) { + logger.debug(null, "Result from initiating workflow: " + JSON.stringify(result)); + return getWorkflowResult(result.executionId); + }); +}; + +// Wait for workflow to complete and get result +exports.getWorkflowResult = getWorkflowResult; + +// Retrieves outputs for a deployment +exports.getOutputs = function(dpid) { + var reqOptions = { + method : "GET", + uri : cfyAPI + "/deployments/" + dpid + "/outputs", + headers : { + "Accept" : "*/*" + } + }; + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + + return doRequest(reqOptions); +}; + +// Get the output descriptions for a deployment +exports.getOutputDescriptions = function(dpid) { + var reqOptions = { + method : "GET", + uri : cfyAPI + "/deployments/" + dpid + "?include=outputs", + headers : { + "Accept" : "*/*" + } + }; + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + + return doRequest(reqOptions); +}; + +// Deletes a deployment +exports.deleteDeployment = function(dpid) { + var reqOptions = { + method : "DELETE", + uri : cfyAPI + "/deployments/" + dpid + }; + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + + return doRequest(reqOptions); +}; + +// Deletes a blueprint +exports.deleteBlueprint = function(bpid) { + var reqOptions = { + method : "DELETE", + uri : cfyAPI + "/blueprints/" + bpid + }; + if (cfyAuth) { + reqOptions.auth = cfyAuth; + } + + return doRequest(reqOptions); +}; + +// Allow client to set the Cloudify API root address +exports.setAPIAddress = function(addr) { + cfyAPI = addr; +}; + +// Allow client to set Cloudify credentials +exports.setCredentials = function(user, password) { + cfyAuth = user + ':' + password; +}; + +// Set a logger +exports.setLogger = function(log) { + logger = log; +}; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..4429247 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,213 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +/* + * Dispatcher configuration + * Configuration may come from environment variables, a value in a Consul key-value store, or defaults, + * in that order of precedence. + * + * The address of the Consul host is passed in an environment variable called CONSUL_HOST. + * If present, the configuration value in the key-value store is a UTF-8 serialization of a JSON object. + * + * + * -------------------------------------------------------------------------------------- + * | JSON property | Environment variable | Required? | Default | + * -------------------------------------------------------------------------------------- + * | logLevel | LOG_LEVEL | Yes | "INFO" | + * -------------------------------------------------------------------------------------- + * | listenHost | LISTEN_HOST | Yes | "0.0.0.0" | + * -------------------------------------------------------------------------------------- + * | listenPort | LISTEN_PORT | Yes | 8443 | + * -------------------------------------------------------------------------------------- + * | cloudify.user | CLOUDIFY_USER | No | none | + * -------------------------------------------------------------------------------------- + * | cloudify.password | CLOUDIFY_PASSWORD | No | none | + * -------------------------------------------------------------------------------------- + * | cloudify.protocol | CLOUDIFY_PROTOCOL | No | "https" | + * -------------------------------------------------------------------------------------- + * | inventory.user | INVENTORY_USER | No | none | + * -------------------------------------------------------------------------------------- + * | inventory.password | INVENTORY_PASSWORD | No | none | + * -------------------------------------------------------------------------------------- + * | inventory.protocol | INVENTORY_PROTOCOL | No | "https" | + * -------------------------------------------------------------------------------------- + * | auth | (no environment var) | No | none | + * -------------------------------------------------------------------------------------- + * auth, if present, is a JSON object, with property names corresponding to user names and + * property values corresponding to passwords. If the auth property has the value: + * {"admin" : "admin123", "other" : "other123"}, then any incoming HTTP requests must use + * Basic authentication and supply "admin" as a user name with "admin123" as the password or + * supply "other" as the user name with "other123" as the password. + * + * The dispatcher will attempt to run using TLS (i.e., as an HTTPS server) if a certificate + * file in pkcs12 format is stored at etc/cert/cert and a file containing the corresponding + * passphrase is stored at etc/cert/pass. These files can be made available to the container + * running the dispatcher by mounting a volume to the container. + */ +"use strict"; + +const fs = require("fs"); +const utils = require("./utils"); +const consul = require("./consul"); + +const SSL_CERT_FILE = "etc/cert/cert"; +const SSL_PASS_FILE = "etc/cert/pass"; + +const CONFIG_KEY = "deployment_handler"; /* Configuration is stored under the name "deployment_handler" */ +const CM_NAME = "cloudify_manager"; +const INV_NAME = "inventory"; + +const CM_API_PATH = "/api/v2.1"; +const INV_API_PATH = ""; + +const DEFAULT_CLOUDIFY_PROTOCOL = "https"; +const DEFAULT_INVENTORY_PROTOCOL = "https"; +const DEFAULT_LISTEN_PORT = 8443; +const DEFAULT_LISTEN_HOST = "0.0.0.0"; +const DEFAULT_LOG_LEVEL = "INFO"; + +/* Check configuration for completeness */ +const findMissingConfig = function(cfg) { + const requiredProps = ['logLevel', 'listenHost', 'listenPort', 'cloudify.url', 'inventory.url']; + return requiredProps.filter(function(p){return !utils.hasProperty(cfg,p);}); +}; + +/* Fetch configuration */ +const getConfig = function (configStoreAddress) { + const ch = consul({url: configStoreAddress}); + return ch.getKey(CONFIG_KEY) + .then(function(res) { + return res || {}; + }) + .catch(function(err) { + throw err; + }); +}; + +/* Get a service host:port */ +const getService = function (configStoreAddress, serviceName) { + const ch = consul({url: configStoreAddress}); + return ch.getService(serviceName) + .then(function(res) { + if (res.length > 0) { + return res[0]; + } + else { + throw new Error("No service address found for " + serviceName); + } + }) +}; + +/* Get the content of a file */ +const getFileContents = function(path) { + return new Promise(function(resolve, reject) { + fs.readFile(path, function(err, data) { + if (err) { + reject(err); + } + else { + resolve(data); + } + }) + }) +}; + +/* Check for a TLS cert file and passphrase */ +const getTLSCredentials = function() { + var ssl = {}; + + /* Get the passphrase */ + return getFileContents(SSL_PASS_FILE) + .then(function(phrase) { + ssl.passphrase = phrase.toString('utf8').trim(); + + /* Get the cert */ + return getFileContents(SSL_CERT_FILE); + }) + + .then(function(cert) { + ssl.pfx = cert; /* Keep cert contents as a Buffer */ + return ssl; + }) + + .catch(function(err) { + return {}; + }); +} + +exports.configure = function(configStoreAddress) { + + var config = {}; + + /* Construct a URL for Consul, assuming HTTP and the default Consul port */ + const configStoreURL = 'http://' + configStoreAddress + ':8500'; + + /* Get configuration from configuration store */ + return getConfig(configStoreURL) + .then (function(cfg) { + + config = cfg; + + /* Override values with environment variables and set defaults as needed */ + config.listenPort = process.env.LISTEN_PORT || cfg.listenPort || DEFAULT_LISTEN_PORT; + config.listenHost = process.env.LISTEN_HOST || cfg.listenHost || DEFAULT_LISTEN_HOST; + config.logLevel = process.env.LOG_LEVEL || cfg.logLevel || DEFAULT_LOG_LEVEL; + + config.cloudify = config.cloudify || {}; + config.cloudify.protocol = process.env.CLOUDIFY_PROTOCOL || (cfg.cloudify && cfg.cloudify.protocol) || DEFAULT_CLOUDIFY_PROTOCOL; + if ((cfg.cloudify && cfg.cloudify.user) || process.env.CLOUDIFY_USER) { + config.cloudify.user = process.env.CLOUDIFY_USER || cfg.cloudify.user; + config.cloudify.password = process.env.CLOUDIFY_PASSWORD || cfg.cloudify.password || ""; + } + + config.inventory = config.inventory || {}; + config.inventory.protocol = process.env.INVENTORY_PROTOCOL || (cfg.inventory && cfg.inventory.protocol) || DEFAULT_INVENTORY_PROTOCOL; + if ((cfg.inventory && cfg.inventory.user)|| process.env.INVENTORY_USER) { + config.inventory.user = process.env.INVENTORY_USER || cfg.inventory.user; + config.inventory.password = process.env.INVENTORY_PASSWORD || cfg.inventory.password || ""; + } + + /* Get service information for Cloudify Manager */ + return getService(configStoreURL, CM_NAME); + }) + + .then(function(cmService) { + + config.cloudify.url = config.cloudify.protocol +"://" + cmService.address + ":" + cmService.port + CM_API_PATH; + + /* Get service information for inventory */ + return getService(configStoreURL, INV_NAME); + }) + + .then(function(invService) { + config.inventory.url = config.inventory.protocol + "://" + invService.address + ":" + invService.port + INV_API_PATH; + + /* Get TLS credentials, if they exist */ + return getTLSCredentials(); + }) + .then(function(tls) { + config.ssl = tls; + + /* Check for missing required configuration parameters */ + const missing = findMissingConfig(config); + if (missing.length > 0) { + throw new Error ("Required configuration elements missing: " + missing.join(',')); + config = null; + } + + return config; + }); +}; diff --git a/lib/consul.js b/lib/consul.js new file mode 100644 index 0000000..5b76ffc --- /dev/null +++ b/lib/consul.js @@ -0,0 +1,66 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +/* Low-level routines for using the Consul REST API */ + +const KEY = '/v1/kv/'; +const SERVICE = '/v1/catalog/service/'; +const DEFAULT_URL = 'http://localhost:8500'; + +const doRequest = require('./promise_request').doRequest; + +module.exports = function(options) { + const url = options.url || DEFAULT_URL; + + return { + + /* Fetch (a promise for) the decoded value of a single key from Consul KV store. + * If the value is a string representation of a JSON object, return as an object. + * If there is no such key, resolve to null. + */ + getKey: function(key) { + return doRequest({method: 'GET', uri: url + KEY + key + '?raw'}) + .then(function(res) { + return res.json || res.body; + }) + .catch(function(err) { + if (err.status === 404) { + /* Key wasn't found */ + return null; + } + else { + /* Some other error, rethrow it */ + throw err; + } + }); + }, + + /* Retrieve (a promise for) address:port information for a named service from the Consul service catalog. + * If the service has tag(s), return the first one. (Should be the full URL of the service if it exists. + * Since a service can be registered at multiple nodes, the result is an array. + * If the service is not found, returns a zero-length array. + */ + getService: function(serviceId) { + return doRequest({method: 'GET', uri: url + SERVICE + serviceId}) + .then(function(res){ + return res.json.map(function(r) { + /* Address for external service is in r.Address with r.ServiceAddress empty */ + return {address: r.ServiceAddress || r.Address, port: r.ServicePort, url: r.ServiceTags ? r.ServiceTags[0] : ""}; + }); + }); + } + } +};
\ No newline at end of file diff --git a/lib/dcae-deployments.js b/lib/dcae-deployments.js new file mode 100644 index 0000000..bcec0e5 --- /dev/null +++ b/lib/dcae-deployments.js @@ -0,0 +1,234 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +/* Handle the /dcae-deployments API */ + +"use strict"; + +/* Set this code up as a "sub-app"--lets us get the mountpoint for creating links */ +const app = require('express')(); +app.set('x-powered-by', false); +app.set('etag', false); + +const bodyParser = require('body-parser'); +const deploy = require('./deploy'); +const middleware = require('./middleware'); +const inv = require('./inventory'); +const log = require('./logging').getLogger(); + +/* Pick up config exported by main */ +const config = process.mainModule.exports.config; +const inventory = inv({url: config.inventory.url}); + +/* Set up middleware stack for initial processing of request */ +app.use(middleware.checkType('application/json')); // Validate type +app.use(bodyParser.json({strict: true})); // Parse body as JSON + + +/* Return a promise for a blueprint for the given service type ID */ +const getBlueprint = function(serviceTypeId) { + return inventory.getBlueprintByType(serviceTypeId) + .then(function (blueprintInfo) { + if (!blueprintInfo.blueprint) { + var e = new Error("No service type with ID " + serviceTypeId); + e.status = 404; + throw e; + } + return blueprintInfo; + }) +}; + +/* Generate self and status links object for responses */ +const createLinks = function(req, deploymentId, executionId) { + var baseURL = req.protocol + '://' + req.get('Host') + req.app.mountpath + '/' + deploymentId; + return { + self: baseURL, + status: baseURL + '/operation/' + executionId + }; +}; + +/* Generate a success response body for PUT and DELETE operations */ +const createResponse = function(req, result) { + return { + requestId: req.dcaeReqId, + links: createLinks(req, result.deploymentId, result.executionId) + }; +}; + +/* Look up running (or in process of deploying) instances of the given service type */ +app.get('/', function (req, res, next) { + var services = [] + + + var searchTerm = {}; + + req.query['serviceTypeId'] && (searchTerm = {typeId: req.query['serviceTypeId']}); + + inventory.getServicesByType(searchTerm) + .then(function (result) { + var deployments = result.map(function(service){ + return { + href: req.protocol + '://' + req.get('Host') + req.app.mountpath + '/' + service.deploymentId + }; + }) + res.status(200).json({requestId: req.dcaeReqId, deployments: deployments}); + log.audit(req, 200); + }) + .catch(next); /* Let the error handler send response and log the error */ +}); + +/* Accept an incoming deployment request */ +app.put('/:deploymentId', function(req, res, next) { + + log.debug(req.dcaeRequestId, "body: " + JSON.stringify(req.body)); + + /* Make sure there's a serviceTypeId in the body */ + if (!req.body['serviceTypeId']) { + var e = new Error ('Missing required parameter serviceTypeId'); + e.status = 400; + throw e; + } + + /* Make sure the deploymentId doesn't already exist */ + inventory.verifyUniqueDeploymentId(req.params['deploymentId']) + + /* Get the blueprint for this service type */ + .then(function(res) { + return getBlueprint(req.body['serviceTypeId']); + }) + + /* Add this new service instance to inventory + * Easier to remove from inventory if deployment fails than vice versa + * Also lets client check for deployed/deploying instances if client wants to limit number of instances + */ + .then(function (blueprintInfo) { + req.dcaeBlueprint = blueprintInfo.blueprint; + return inventory.addService(req.params['deploymentId'], blueprintInfo.typeId, "dummyVnfId", "dummyVnfType", "dummyLocation"); + }) + + /* Upload blueprint, create deployment and start install workflow (but don't wait for completion */ + .then (function() { + req.dcaeAddedToInventory = true; + return deploy.launchBlueprint(req.params['deploymentId'], req.dcaeBlueprint, req.body['inputs']); + }) + + /* Send the HTTP response indicating workflow has started */ + .then(function(result) { + res.status(202).json(createResponse(req, result)); + log.audit(req, 202, "Execution ID: " + result.executionId); + return result; + }) + + /* Finish deployment--wait for the install workflow to complete, retrieve and annotate outputs */ + .then(function(result) { + return deploy.finishInstallation(result.deploymentId, result.executionId); + }) + + /* Log completion in audit log */ + .then (function(result) { + log.audit(req, 200, "Deployed id: " + req.params['deploymentId']); + }) + + /* All errors show up here */ + .catch(function(error) { + + /* If we haven't already sent a response, let the error handler send response and log the error */ + if (!res.headersSent) { + + /* If we made an inventory entry, remove it */ + if (req.dcaeAddedToInventory) { + inventory.deleteService(req.params['deploymentId']) + .catch(function(error) { + log.error(error, req); + }); + } + + next(error); + } + else { + /* Already sent the response, so just log error */ + /* Don't remove from inventory, because there is a deployment on CM that might need to be removed */ + error.message = "Error deploying deploymentId " + req.params['deploymentId'] + ": " + error.message + log.error(error, req); + log.audit(req, 500, error.message); + } + + }); +}); + +/* Delete a running service instance */ +app.delete('/:deploymentId', function(req, res, next) { + + /* Launch the uninstall workflow */ + deploy.launchUninstall(req.params['deploymentId']) + + /* Delete the service from inventory */ + .then(function(result) { + return inventory.deleteService(req.params['deploymentId']) + .then (function() { + return result; + }); + }) + + /* Send the HTTP response indicating workflow has started */ + .then(function(result) { + res.status(202).send(createResponse(req, result)); + log.audit(req, 202, "ExecutionId: " + result.executionId); + return result; + }) + + /* Finish the delete processing--wait for the uninstall to complete, delete deployment, delete blueprint */ + .then(function(result) { + return deploy.finishUninstall(result.deploymentId, result.executionId); + }) + + /* Log completion in audit log */ + .then(function(result) { + log.audit(req, 200, "Undeployed id: " + req.params['deploymentId']); + }) + + /* All errors show up here */ + .catch(function(error) { + /* If we haven't already sent a response, give it to the error handler to send response */ + if (!res.headersSent) { + next(error); + } + else { + /* Error happened after we sent the response--log it */ + error.message = "Error undeploying deploymentId " + req.params['deploymentId'] + ": " + error.message + log.error(error, req); + log.audit(req, 500, error.message); + } + }); +}); + +/* Get the status of a workflow execution */ +app.get('/:deploymentId/operation/:executionId', function(req, res, next){ + deploy.getExecutionStatus(req.params['executionId']) + + /* Send success response */ + .then(function(result) { + result.requestId = req.dcaeReqId; + result.links = createLinks(req, req.params['deploymentId'], req.params['executionId']); + res.status(200).json(result); + log.audit(req, 200, "Workflow type: " + result.operationType + " -- execution status: " + result.status); + }) + + .catch(next); /* Let the error handler send the response and log the error */ + +}); + +module.exports = app;
\ No newline at end of file diff --git a/lib/deploy.js b/lib/deploy.js new file mode 100644 index 0000000..741affb --- /dev/null +++ b/lib/deploy.js @@ -0,0 +1,306 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +"use strict"; + +/* Deploy and undeploy using Cloudify blueprints */ + +const config = process.mainModule.exports.config; + +/* Set delays between steps */ +const DELAY_INSTALL_WORKFLOW = 30000; +const DELAY_RETRIEVE_OUTPUTS = 5000; +const DELAY_DELETE_DEPLOYMENT = 30000; +const DELAY_DELETE_BLUEPRINT = 10000; + +const createError = require('./dispatcher-error').createDispatcherError; + +/* Set up logging */ +var logger = require("./logging").getLogger(); + +/* Set up the Cloudify low-level interface library */ +var cfy = require("./cloudify.js"); +/* Set config for interface library */ +cfy.setAPIAddress(config.cloudify.url); +cfy.setCredentials(config.cloudify.user, config.cloudify.password); +cfy.setLogger(logger); + + + + +// Try to parse a string as JSON +var parseContent = function(input) { + var res = {json: false, content: input}; + try { + var parsed = JSON.parse(input); + res.json = true; + res.content = parsed; + } + catch (pe) { + // Do nothing, just indicate it's not JSON and return content as is + } + return res; +}; + +// create a normalized representation of errors, whether they're a node.js Error or a Cloudify API error +var normalizeError = function (err) { + var e; + + if (err instanceof Error) { + /* node.js system error */ + e = createError("Error communicating with CM: " + err.message, 504, "system", 202, 'cloudify-manager'); + } + else { + // Try to populate error with information from a Cloudify API error + // We expect to see err.body, which is a stringified JSON object + // We can parse it and extract message and error_code + var message = err.message || "unknown Cloudify Manager API error"; + var status = err.status || 502; + var cfyCode = "UNKNOWN"; + var cfyMessage; + + if (err.body) { + var p = parseContent(err.body); + if (p.json) { + cfyMessage = p.content.message ? p.content.message : "unknown Cloudify API error"; + cfyCode = p.content.error_code ? p.content.error_code : "UNKNOWN"; + } + else { + // if there's a body and we can't parse it just attach it as the message + cfyMessage = err.body; + } + message = "Status " + status + " from CM API -- error code: " + cfyCode + " -- message: " + cfyMessage; + } + + /* Pass through 400-level status, recast 500-level */ + var returnStatus = (err.status > 499) ? 502 : err.status; + e = createError(message, returnStatus, "api", 502, 'cloudify-manager'); + } + + return e; +}; + +// Augment the raw outputs from a deployment with the descriptions from the blueprint +var annotateOutputs = function (id, rawOutputs) { + return new Promise(function(resolve, reject) { + + var outItems = Object.keys(rawOutputs); + + if (outItems.length < 1) { + // No output items, so obviously no descriptions, just return empty object + resolve({}); + } + else { + // Call Cloudify to get the descriptions + cfy.getOutputDescriptions(id) + .then(function(res) { + // Assemble an outputs object with values from raw output and descriptions just obtained + var p = parseContent(res.body); + if (p.json && p.content.outputs) { + var outs = {}; + outItems.forEach(function(i) { + outs[i] = {value: rawOutputs[i]}; + if (p.content.outputs[i] && p.content.outputs[i].description) { + outs[i].description = p.content.outputs[i].description; + } + }); + resolve(outs); + } + else { + reject({code: "API_INVALID_RESPONSE", message: "Invalid response for output descriptions query"}); + } + }); + } + + }); +}; + +// Delay function--returns a promise that's resolved after 'dtime' milliseconds.` +var delay = function(dtime) { + return new Promise(function(resolve, reject){ + setTimeout(resolve, dtime); + }); +}; + +// Go through the Cloudify API call sequence to upload blueprint, create deployment, and launch install workflow +// (but don't wait for the workflow to finish) +const launchBlueprint = function(id, blueprint, inputs) { + logger.debug(null, "deploymentId: " + id + " starting blueprint upload"); + // Upload blueprint + return cfy.uploadBlueprint(id, blueprint) + + // Create deployment + .then (function(result) { + logger.debug(null, "deploymentId: " + id + " blueprint uploaded"); + // Create deployment + return cfy.createDeployment(id, id, inputs); + }) + + // Launch the workflow, but don't wait for it to complete + .then(function(result){ + logger.debug(null, "deploymentId: " + id + " deployment created"); + return delay(DELAY_INSTALL_WORKFLOW) + .then(function(){ + return cfy.initiateWorkflowExecution(id, 'install'); + }); + }) + .catch(function(error) { + logger.debug(null, "Error: " + error + " for launch blueprint for deploymentId " + id); + throw normalizeError(error); + }) +}; +exports.launchBlueprint = launchBlueprint; + +// Finish installation launched with launchBlueprint +const finishInstallation = function(deploymentId, executionId) { + logger.debug(null, "finishInstallation: " + deploymentId + " -- executionId: " + executionId); + return cfy.getWorkflowResult(executionId) + .then (function(result){ + logger.debug(null, "deploymentId: " + deploymentId + " install workflow successfully executed"); + // Retrieve the outputs from the deployment, as specified in the blueprint + return delay(DELAY_RETRIEVE_OUTPUTS).then(function() { return cfy.getOutputs(deploymentId); }); + }) + .then(function(result) { + // We have the raw outputs from the deployment but not annotated with the descriptions + var rawOutputs = {}; + if (result.body) { + var p = parseContent(result.body); + if (p.json) { + if (p.content.outputs) { + rawOutputs = p.content.outputs; + } + } + } + logger.debug(null, "output retrieval result for " + deploymentId + ": " + JSON.stringify(result)); + return annotateOutputs(deploymentId, rawOutputs); + }) + .catch(function(err) { + logger.debug(null, "Error finishing install workflow: " + err + " -- " + JSON.stringify(err)); + throw normalizeError(err); + }); +} +exports.finishInstallation = finishInstallation; + +// Initiate uninstall workflow against a deployment, but don't wait for workflow to finish +const launchUninstall = function(deploymentId) { + logger.debug(null, "deploymentId: " + deploymentId + " starting uninstall workflow"); + // Run uninstall workflow + return cfy.initiateWorkflowExecution(deploymentId, 'uninstall') + .then(function(result) { + return result; + }) + .catch(function(err) { + logger.debug(null, "Error initiating uninstall workflow: " + err + " -- " + JSON.stringify(err)); + throw normalizeError(err); + }); +}; +exports.launchUninstall = launchUninstall; + +const finishUninstall = function(deploymentId, executionId) { + logger.debug(null, "finishUninstall: " + deploymentId + " -- executionId: " + executionId); + return cfy.getWorkflowResult(executionId) + .then (function(result){ + logger.debug(null, "deploymentId: " + deploymentId + " uninstall workflow successfully executed"); + // Delete the deployment + return delay(DELAY_DELETE_DEPLOYMENT).then(function() {return cfy.deleteDeployment(deploymentId);}); + }) + .then (function(result){ + logger.debug(null, "deploymentId: " + deploymentId + " deployment deleted"); + // Delete the blueprint + return delay(DELAY_DELETE_BLUEPRINT).then(function() {return cfy.deleteBlueprint(deploymentId);}); + }) + .then (function(result){ + return result; + }) + .catch (function(err){ + throw normalizeError(err); + }); + +}; +exports.finishUninstall = finishUninstall; + +// Get the status of a workflow execution +exports.getExecutionStatus = function (exid) { + return cfy.getWorkflowExecutionStatus(exid) + .then(function(res){ + + var result = { + operationType: res.json.workflow_id + } + + // Map execution status + if (res.json.status === "terminated") { + result.status = "succeeded"; + } + else if (res.json.status === "failed") { + result.status = "failed"; + } + else if (res.json.status === "cancelled" || res.stats === "canceled") { + result.status = "canceled"; + } + else { + result.status = "processing"; + } + + if (res.json.error) { + result.error = res.json.error; + } + logger.debug(null, "getExecutionStatus result: " + JSON.stringify(result)); + return result; + }) + .catch(function(error) { + throw normalizeError(error); + }); +}; + +// Go through the Cloudify API call sequence to do a deployment +exports.deployBlueprint = function(id, blueprint, inputs) { + + // Upload blueprint, create deployment, and initiate install workflow + return launchBlueprint(id, blueprint, inputs) + + // Wait for the workflow to complete + .then( + + // launchBlueprint promise fulfilled -- finish installation + function(result){ + return finishInstallation(result.deploymentId, result.executionId); // Will throw normalized error if it fails + }, + + // launchBlueprint promise rejected -- report error + function(err) { + throw normalizeError(err); + }); +}; + +// Go through the Cloudify API call sequence to do an undeployment of a previously deployed blueprint +exports.undeployDeployment = function(id) { + logger.debug(null, "deploymentId: " + id + " starting uninstall workflow"); + + // Run launch uninstall workflow + return launchUninstall(id) + + // launchUninstall promise fulfilled -- finish uninstall + .then (function(result){ + return finishUninstall(result.deploymentId, result.executionId); // Will throw normalized error if it fails + }, + + // launchUninstall promise rejected -- report error + function(err){ + throw normalizeError(err); + }); +}; + diff --git a/lib/dispatcher-error.js b/lib/dispatcher-error.js new file mode 100644 index 0000000..ae51fcc --- /dev/null +++ b/lib/dispatcher-error.js @@ -0,0 +1,53 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +"use strict"; + +/* + * Extend the standard Error type by appending fields to capture more information at the + * point of detection. The error information guides dispatcher's response to the incoming HTTP request + * that triggered the error and helps make the error log more specific and meaningful. + * This type of Error typically reports on problems encountered when attempting to use a downstream API. + * + * The standard Error has two fields: + * - name: the name of the Error, which is 'Error' + * - message: a text description of the error + * + * For dispatcher purposes, we add: + * - status: the HTTP status code that dispatcher should use in its response + * - type: "system" or "api" depending on whether the error was the result of a failed system call or + * an error reported by the downstream API. + * - logCode: the error code to use in the log entry. + * - target: the downstream system dispatcher was attempting to interact with + * + * Note that we're not defining a new class, just adding fields to the existing Error type. This pattern is + * used in Node for system errors. + */ + +/* Create an error given the parameters */ +exports.createDispatcherError = function(message, status, type, logCode, target) { + var e = new Error(); + + e.message = message || 'no error information'; + e.status = status || 500; + e.type = type; + e.logCode = logCode || 900; + e.target = target || ''; + + return e; +}; + + diff --git a/lib/info.js b/lib/info.js new file mode 100644 index 0000000..94d6455 --- /dev/null +++ b/lib/info.js @@ -0,0 +1,38 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +/* Handle the / API that provides API information */ + +"use strict"; + +const router = require('express').Router(); + +/* Pick up config exported by main */ +const config = process.mainModule.exports.config; + +/* Accept an incoming event */ +router.get('/', function(req, res) { + res.json( + { + apiVersion: config.apiVersion, + serverVersion: config.version, + links: config.apiLinks + } + ); + require('./logging').getLogger().audit(req, 200); +}); + +module.exports = router;
\ No newline at end of file diff --git a/lib/inventory.js b/lib/inventory.js new file mode 100644 index 0000000..75a0e47 --- /dev/null +++ b/lib/inventory.js @@ -0,0 +1,169 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + + /* Routines related to accessing DCAE inventory */ + + "use strict"; + + const req = require('./promise_request'); + const createError = require('./dispatcher-error').createDispatcherError; + + const INV_SERV_TYPES = '/dcae-service-types'; + const INV_SERVICES = '/dcae-services'; + + /* + * Common error handling for inventory API calls + */ + const invError = function(err) { + if (err.status && err.status === 404) { + /* Map 404 to an empty list */ + return []; + } + else { + var newErr; + var message; + if (err.status) { + /* Got a response from inventory indicating an error */ + message = "Error response " + err.status + " from DCAE inventory: " + err.body; + newErr = createError(message, 502, "api", 501, "dcae-inventory"); + } + else { + /* Problem connecting to inventory */ + message = "Error communicating with inventory: " + err.message; + newErr = createError(message, 504, "system", 201, "dcae-inventory"); + } + throw newErr; + } + }; + + module.exports = function(options) { + + const url = options.url; + + return { + + /* Add a DCAE service to the inventory. Done after a deployment.*/ + addService: function(deploymentId, serviceType, vnfId, vnfType, vnfLocation, outputs) { + + /* Create the service description */ + var serviceDescription = + { + "vnfId" : vnfId, + "vnfType" : vnfType, + "vnfLocation" : vnfLocation, + "typeId" : serviceType, + "deploymentRef" : deploymentId + }; + + // TODO create 'components' array using 'outputs'--for now, a dummy + serviceDescription.components = [ + { + componentType: "dummy_component", + componentId: "/components/dummy", + componentSource: "DCAEController", + shareable: 0 + } + ]; + + const reqOptions = { + method : "PUT", + uri : url + INV_SERVICES + "/" + deploymentId, + json: serviceDescription + }; + + return req.doRequest(reqOptions); + }, + + /* Remove a DCAE service from the inventory. Done after an undeployment. */ + deleteService: function(serviceId) { + return req.doRequest({method: "DELETE", uri: url + INV_SERVICES + "/" + serviceId}); + }, + + /* Find running/deploying instances of services (with a given type name, if specified) */ + getServicesByType: function(query) { + var options = { + method: 'GET', + uri: url + INV_SERVICES, + qs: query || {} + }; + + return req.doRequest(options) + .then (function (result) { + var services = []; + var content = JSON.parse(result.body); + if(content.items) { + /* Pick out the fields we want */ + services = content.items.map(function(i) { return { deploymentId: i.deploymentRef, serviceTypeId: i.typeId};}); + } + return services; + }) + .catch(invError); + }, + + /* Find a blueprint given the service type ID -- return blueprint and type ID */ + getBlueprintByType: function(serviceTypeId) { + return req.doRequest({ + method: "GET", + uri: url + INV_SERV_TYPES + '/' + serviceTypeId + }) + .then (function(result) { + var blueprintInfo = {}; + var content = JSON.parse(result.body); + blueprintInfo.blueprint = content.blueprintTemplate; + blueprintInfo.typeId = content.typeId; + + return blueprintInfo; + }) + .catch(invError); + }, + + /* + * Verify that the specified deployment ID does not already have + * an entry in inventory. This is needed to enforce the rule that + * creating a second instance of a deployment under the + * same ID as an existing deployment is not permitted. + * The function checks for a service in inventory using the + * deployment ID as service name. If it doesn't exist, the function + * resolves its promise. If it *does* exist, then it throws an error. + */ + verifyUniqueDeploymentId: function(deploymentId) { + + return req.doRequest({ + method: "GET", + uri: url + INV_SERVICES + "/" + deploymentId + }) + + /* Successful lookup -- the deployment exists, so throw an error */ + .then(function(res) { + throw createError("Deployment " + deploymentId + " already exists", 409, "api", 501); + }, + + /* Error from the lookup -- either deployment ID doesn't exist or some other problem */ + function (err) { + + /* Inventory returns a 404 if it does not find the deployment ID */ + if (err.status === 404) { + return true; + } + + /* Some other error -- it really is an error and we can't continue */ + else { + return invError(err); + } + }); + } + }; + }; diff --git a/lib/logging.js b/lib/logging.js new file mode 100644 index 0000000..5bfd48a --- /dev/null +++ b/lib/logging.js @@ -0,0 +1,266 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +"use strict"; + +const os = require('os'); + +const log4js = require('log4js'); +log4js.configure('etc/log4js.json'); + +const auditLogger = log4js.getLogger('audit'); +const errorLogger = log4js.getLogger('error'); +const metricsLogger = log4js.getLogger('metrics'); +const debugLogger = log4js.getLogger('debug'); + +/* Audit log fields */ +const AUDIT_BEGIN = 0; +const AUDIT_END = 1; +const AUDIT_REQID = 2; +const AUDIT_SVCINST = 3; +const AUDIT_THREAD = 4; +const AUDIT_SRVNAME = 5; +const AUDIT_SVCNAME = 6; +const AUDIT_PARTNER = 7; +const AUDIT_STATUSCODE = 8; +const AUDIT_RESPCODE = 9; +const AUDIT_RESPDESC = 10; +const AUDIT_INSTUUID = 11; +const AUDIT_CATLOGLEVEL = 12; +const AUDIT_SEVERITY = 13; +const AUDIT_SRVIP = 14; +const AUDIT_ELAPSED = 15; +const AUDIT_SERVER = 16; +const AUDIT_CLIENTIP = 17; +const AUDIT_CLASSNAME = 18; +const AUDIT_UNUSED = 19; +const AUDIT_PROCESSKEY = 20; +const AUDIT_CUSTOM1 = 21; +const AUDIT_CUSTOM2 = 22; +const AUDIT_CUSTOM3 = 23; +const AUDIT_CUSTOM4 = 24; +const AUDIT_DETAILMSG = 25; +const AUDIT_NFIELDS = 26; + +/* Error log fields */ +const ERROR_TIMESTAMP = 0; +const ERROR_REQID = 1; +const ERROR_THREAD = 2; +const ERROR_SVCNAME = 3; +const ERROR_PARTNER = 4; +const ERROR_TGTENTITY = 5; +const ERROR_TGTSVC = 6; +const ERROR_CATEGORY = 7; +const ERROR_CODE = 8; +const ERROR_DESCRIPTION = 9; +const ERROR_MESSAGE = 10; +const ERROR_NFIELDS = 11; + +/* Error code -> description mapping */ +const descriptions = { + + 201: 'Inventory communication error', + 202: 'Cloudify Manager communication error', + + 501: 'Inventory API error', + 502: 'Cloudify Manager API error', + + 551: 'HTTP(S) Server initialization error', + 552: 'Dispatcher start-up error', + + 999: 'Unknown error' +}; + +/* Metrics log fields */ +const METRICS_BEGIN = 0; +const METRICS_END = 1; +const METRICS_REQID = 2; +const METRICS_SVCINST= 3; +const METRICS_THREAD = 4; +const METRICS_SRVNAME = 5; +const METRICS_SVCNAME = 6; +const METRICS_PARTNER = 7; +const METRICS_TGTENTITY = 8; +const METRICS_TGTSVC = 9; +const METRICS_STATUSCODE = 10; +const METRICS_RESPCODE = 11; +const METRICS_RESPDESC = 12; +const METRICS_INSTUUID = 13; +const METRICS_CATLOGLEVEL = 14; +const METRICS_SEVERITY = 15; +const METRICS_SRVIP = 16; +const METRICS_ELAPSED = 17; +const METRICS_SERVER = 18; +const METRICS_CLIENTIP = 19; +const METRICS_CLASSNAME = 20; +const METRICS_UNUSED = 21; +const METRICS_PROCESSKEY = 22; +const METRICS_TGTVIRTENTITY = 23; +const METRICS_CUSTOM1 = 24; +const METRICS_CUSTOM2 = 25; +const METRICS_CUSTOM3 = 26; +const METRICS_CUSTOM4 = 27; +const METRICS_DETAILMSG = 28; +const METRICS_NFIELDS = 29; + +/* Debug log fields */ +const DEBUG_TIMESTAMP = 0; +const DEBUG_REQID = 1; +const DEBUG_INFO = 2; +const DEBUG_EOR = 3; +const DEBUG_NFIELDS = 4; +const DEBUG_MARKER = '^\n'; + + +/* Format audit record for an incoming API request */ +const formatAuditRecord = function(req, status, extra) { + var rec = new Array(AUDIT_NFIELDS); + const end = new Date(); + rec[AUDIT_END] = end.toISOString(); + rec[AUDIT_BEGIN] = req.startTime.toISOString(); + rec[AUDIT_REQID] = req.dcaeReqId; + rec[AUDIT_SRVNAME] = req.hostname; // Use the value from the Host header + rec[AUDIT_SVCNAME] = req.method + ' ' + req.originalUrl; // Method and URL identify the operation being performed + rec[AUDIT_STATUSCODE] = (status < 300 ) ? "COMPLETE" : "ERROR"; + rec[AUDIT_RESPCODE] = status; // Use the HTTP status code--does not match the table in the logging spec, but makes more sense + rec[AUDIT_CATLOGLEVEL] = "INFO"; // The audit records are informational, regardless of the outcome of the operation + rec[AUDIT_SRVIP] = req.socket.address().address; + rec[AUDIT_ELAPSED] = end - req.startTime; + rec[AUDIT_SERVER] = req.hostname // From the Host header, again + rec[AUDIT_CLIENTIP] = req.connection.remoteAddress; + + if (extra) { + rec[AUDIT_DETAILMSG]= extra.replace(/\n/g, " "); /* Collapse multi-line extra data to a single line */ + } + return rec.join('|'); +}; + +/* Format metrics record for internal processing */ +/* opInfo has: + * startTime -- operation start time in millis + * complete -- true if operation completed successfully, false if failed + * respCode -- response code received from downstream system, if any + * respDesc -- response description received from downstream system, if any + * targetEntity -- name or identifier of downstream system used for subrequest, if any + * targetRequest -- request made to downstream system, if any + */ +const formatMetricsRecord = function(req, opInfo, extra) { + var rec = new Array(METRICS_NFIELDS); + const end = new Date(); + rec[METRICS_END] = end.toISOString(); + rec[METRICS_BEGIN] = opInfo.startTime.toISOString(); + + /* If reporting on a suboperation invoked as a result of an incoming request, capture info about that request */ + if (req) { + rec[METRICS_REQID] = req.dcaeReqId; + rec[METRICS_SRVNAME] = req.hostname; // Use name from the host header + rec[METRICS_SVCNAME] = req.method + ' ' + req.originalUrl; // Method and URL identify the operation being performed + rec[METRICS_CLIENTIP] = req.connection.remoteAddress; + rec[METRICS_SRVIP] = req.socket.address().address; + } + else { + /* No incoming request */ + rec[METRICS_REQID] = 'no incoming request'; + rec[METRICS_SRVNAME] = os.hostname(); + rec[METRICS_SVCNAME] = 'no incoming request'; + } + + rec[METRICS_TGTENTITY] = opInfo.targetEntity; + rec[METRICS_TGTSVC] = opInfo.targetService; + rec[METRICS_STATUSCODE] = opInfo.complete ? "COMPLETE" : "ERROR"; + rec[METRICS_RESPCODE] = opInfo.respCode; + rec[METRICS_CATLOGLEVEL] = "INFO"; // The audit records are informational, regardless of the outcome of the operation + + rec[METRICS_ELAPSED] = end - opInfo.startTime; + rec[METRICS_SERVER] = rec[METRICS_SRVNAME]; + + if (extra) { + rec[METRICS_DETAILMSG]= extra.replace(/\n/g, " "); /* Collapse multi-line extra data to a single line */ + } + return rec.join('|'); +}; + +/* Format error log record */ +const formatErrorRecord = function(category, code, detail, req, target) { + var rec = new Array(ERROR_NFIELDS); + + /* Common fields */ + rec[ERROR_TIMESTAMP] = (new Date()).toISOString(); + rec[ERROR_CATEGORY] = category; + rec[ERROR_CODE] = code; + rec[ERROR_DESCRIPTION] = descriptions[code] || 'no description available'; + + /* Log error detail in a single line if provided */ + if (detail) { + rec[ERROR_MESSAGE] = detail.replace(/\n/g, " "); + } + + /* Fields available if the error happened during processing of an incoming API request */ + if (req) { + rec[ERROR_REQID] = req.dcaeReqId; + rec[ERROR_SVCNAME] = req.method + ' ' + req.originalUrl; // Method and URL identify the operation being performed + rec[ERROR_PARTNER] = req.connection.remoteAddress; // We don't have the partner's name, but we know the remote IP address + } + + /* Include information about the target entity/service if available */ + if (target) { + rec[ERROR_TGTENTITY] = target.entity || ''; + rec[ERROR_TGTSVC] = target.service || ''; + } + return rec.join('|'); +}; + +/* Format debug log record */ +const formatDebugRecord = function(reqId, msg) { + var rec = new Array(DEBUG_NFIELDS); + + rec[DEBUG_TIMESTAMP] = new Date().toISOString(); + rec[DEBUG_REQID] = reqId || ''; + rec[DEBUG_INFO] = msg; + rec[DEBUG_EOR] = DEBUG_MARKER; + + return rec.join('|'); + +}; + +exports.getLogger = function() { + return { + + audit: function(req, status, extra) { + auditLogger.info(formatAuditRecord(req, status, extra)); + }, + + error: function(error, req) { + errorLogger.error(formatErrorRecord("ERROR", error.logCode, error.message, req, {entity: error.target})); + }, + + warn: function(error, req) { + errorLogger.error(formatErrorRecord("WARN", error.logCode, error.message, req, {entity: error.target})); + }, + + metrics: function(req, opInfo, extra) { + metricsLogger.info(formatMetricsRecord(req, opInfo, extra)); + }, + + debug: function(reqId, msg) { + debugLogger.debug(formatDebugRecord(reqId, msg)); + } + } +}; + +exports.setLevel = function(level) { + log4js.setGlobalLogLevel(level); +}; diff --git a/lib/middleware.js b/lib/middleware.js new file mode 100644 index 0000000..183cf77 --- /dev/null +++ b/lib/middleware.js @@ -0,0 +1,77 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +/* Middleware modules */ + +"use strict"; + +const utils = require('./utils'); +const log = require('./logging').getLogger(); + +/* Assign a request ID and start time to each incoming request */ +exports.assignId = function(req, res, next) { + /* Use request ID from header if available, otherwise generate one */ + req.startTime = new Date(); + req.dcaeReqId = req.get('X-ECOMP-RequestID') || utils.generateId(); + next(); +}; + + +/* Error handler -- send error with JSON body */ +exports.handleErrors = function(err, req, res, next) { + var status = err.status || 500; + var msg = err.message || err.body || 'unknown error' + res.status(status).type('application/json').send({status: status, message: msg }); + log.audit(req, status, msg); + + if (status >= 500) { + log.error(err, req); + } +}; + +/* Make sure Content-Type is correct for POST and PUT */ +exports.checkType = function(type){ + return function(req, res, next) { + const ctype = req.header('content-type'); + const method = req.method.toLowerCase(); + /* Content-Type matters only for POST and PUT */ + if (ctype === type || ['post','put'].indexOf(method) < 0) { + next(); + } + else { + var err = new Error ('Content-Type must be \'' + type +'\''); + err.status = 415; + next (err); + } + }; +}; + +/* Check that a JSON body has a set of properties */ +exports.checkProps = function(props) { + return function (req, res, next) { + const missing = props.filter(function(p){return !utils.hasProperty(req.body,p);}); + if (missing.length > 0) { + var err = new Error ('Request missing required properties: ' + missing.join(',')); + err.status = 400; + next(err); + } + else { + next(); + } + }; +}; + + diff --git a/lib/promise_request.js b/lib/promise_request.js new file mode 100644 index 0000000..ca09eee --- /dev/null +++ b/lib/promise_request.js @@ -0,0 +1,114 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + + /* Promise-based HTTP request client */ + + "use strict"; + +/* + * Make an HTTP request using a string for the body + * of the request. + * Return a promise for result in the form + * {status: <http status code>, body: <response body>} + */ + +const http = require('http'); +const https = require('https'); +const url = require('url'); +const querystring = require('querystring'); + +exports.doRequest = function(options, body) { + + return new Promise(function(resolve, reject) { + + var reqBody = null; + if (options.json) { + reqBody = JSON.stringify(options.json); + options.headers = options.headers || {}; + options.headers['Content-Type'] = 'application/json'; + } + else if (body) { + reqBody = body; + } + + if (options.uri) { + var parsed = url.parse(options.uri); + options.protocol = parsed.protocol; + options.hostname = parsed.hostname; + options.port = parsed.port; + options.path = parsed.path; + if (options.qs) { + options.path += ('?' + querystring.stringify(options.qs)); + } + } + + try { + var req = (options.protocol === 'https:' ? https.request(options) : http.request(options)); + } + catch (e) { + reject(e); + } + + // Reject promise if there's an error + req.on('error', function(error) { + reject(error); + }); + + // Capture the response + req.on('response', function(resp) { + + // Collect the body of the response + var rbody = ''; + resp.on('data', function(d) { + rbody += d; + }); + + // resolve or reject when finished + resp.on('end', function() { + + var result = { + status : resp.statusCode, + body : rbody + }; + + // Add a JSON version of the body if appropriate + if (rbody.length > 0) { + try { + var jbody = JSON.parse(rbody); + result.json = jbody; + } + catch (pe) { + // Do nothing, no json property added to the result object + } + } + + if (resp.statusCode > 199 && resp.statusCode < 300) { + // HTTP status code indicates success - resolve the promise + resolve(result); + } + else { + // Reject the promise + reject(result); + } + }); + }); + + if (reqBody) { + req.write(reqBody, 'utf8'); + } + req.end(); + }); +}; diff --git a/lib/repeat.js b/lib/repeat.js new file mode 100644 index 0000000..2ea0e14 --- /dev/null +++ b/lib/repeat.js @@ -0,0 +1,54 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +"use strict"; + +/** + * Returns a promise for running and re-running the specified action until the result meets a specific condition + * - action is a function that returns a promise + * - predicate is a function that takes a success result from action and returns true if the action should be rerun + * - maxTries is the total number of times to try the action + * - interval is the interval, in milliseconds, between tries, as approximated by setTimeout() + */ + +exports.repeatWhile = function(action, predicate, maxTries, interval) { + return new Promise(function(resolve, reject) { + + var count = 0; + + function makeAttempt() { + action() + .then (function(res) { + if (!predicate(res)) { + // We're done + resolve(res); + } + else { + if (++count < maxTries) { + // set up next attempt + setTimeout(makeAttempt, interval); + } + else { + // we've run out of retries or it's not retryable, so reject the promise + reject({message: "maximum repetions reached: " + count }); + } + } + }); + } + + makeAttempt(); + }); +}; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..70146e3 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,39 @@ +/* +Copyright(c) 2017 AT&T Intellectual Property. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and limitations under the License. +*/ + +"use strict"; + +const uuid = require('uuid/v4'); + +// Utility functions + +/* Does object 'o' have property 'key' */ +exports.hasProperty = function(o, key) { + return key.split('.').every(function(e){ + if (typeof(o) === 'object' && o !== null && (e in o) && (typeof o[e] !== 'undefined')) { + o = o[e]; + return true; + } + else { + return false; + } + }); +}; + +/* Generate a random ID string */ +exports.generateId = function() { + return uuid(); +}; |