Add support to run an ansible playbook

- Add an API to upload a playbook
- Add an API to run an ansible playbook on a rackHD node
- fix new eslint errors
- Add unit tests

Change-Id: Ibe623c228c6ac13cab0f2726e267878bec1bdb6e
Implements: blueprint shovel-deployment-capability
This commit is contained in:
Andre Keedy 2016-03-03 11:59:05 -05:00 committed by Andre keedy
parent 9449f7a443
commit 50ec0f2000
12 changed files with 381 additions and 73 deletions

View File

@ -589,6 +589,40 @@
}
}
}
},
"/run/ansible-playbook/{identifier}": {
"post": {
"x-swagger-router-controller": "Shovel",
"tags": [ "Nodes Provisionning" ],
"operationId": "runAnsible",
"summary": "run the uploaded ansible playbook",
"parameters": [
{
"name": "identifier",
"in": "path",
"description": "rackHD node ID",
"required": true,
"type": "string"
},
{
"name": "Config",
"in": "body",
"description": "OS Configuration",
"required": true,
"schema": {
"$ref": "#/definitions/run-ansible"
}
}
],
"responses": {
"200": {
"description": "Not Implemented"
},
"default": {
"description": "unexpected error"
}
}
}
}
},
"definitions": {
@ -753,7 +787,7 @@
"properties": {
"name": {
"type": "string",
"example":"Graph.InstallCentOS"
"example": "Graph.InstallCentOS"
},
"options": {
"type": "object",
@ -823,6 +857,25 @@
}
}
}
},
"run-ansible": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"example": "runExample"
},
"vars": {
"type": "object"
},
"playbookPath": {
"type": "string",
"example": "files/extract/main.yml"
}
}
}
}
}

View File

@ -278,20 +278,20 @@ module.exports.registerpost = function registerpost(req, res) {
userEntry = req.body;
if (userEntry.driver === 'pxe_ipmitool') {
info = {
'ipmi_address': userEntry.ipmihost,
'ipmi_username': userEntry.ipmiuser,
'ipmi_password': userEntry.ipmipass,
'deploy_kernel': userEntry.kernel,
'deploy_ramdisk': userEntry.ramdisk
ipmi_address: userEntry.ipmihost,
ipmi_username: userEntry.ipmiuser,
ipmi_password: userEntry.ipmipass,
deploy_kernel: userEntry.kernel,
deploy_ramdisk: userEntry.ramdisk
};
} else if (userEntry.driver === 'pxe_ssh') {
info = {
'ssh_address': userEntry.sshhost,
'ssh_username': userEntry.sshuser,
'ssh_password': userEntry.sshpass,
'ssh_port': userEntry.sshport,
'deploy_kernel': userEntry.kernel,
'deploy_ramdisk': userEntry.ramdisk
ssh_address: userEntry.sshhost,
ssh_username: userEntry.sshuser,
ssh_password: userEntry.sshpass,
ssh_port: userEntry.sshport,
deploy_kernel: userEntry.kernel,
deploy_ramdisk: userEntry.ramdisk
};
} else {
info = {};
@ -299,11 +299,11 @@ module.exports.registerpost = function registerpost(req, res) {
/* Fill in the extra meta data with some failover and event data */
extra = {
'nodeid': userEntry.uuid,
'name': userEntry.name,
'lsevents': { 'time': 0 },
'eventcnt': 0,
'timer': {}
nodeid: userEntry.uuid,
name: userEntry.name,
lsevents: { time: 0 },
eventcnt: 0,
timer: {}
};
if (typeof userEntry.failovernode !== 'undefined') {
extra.failover = userEntry.failovernode;
@ -315,7 +315,7 @@ module.exports.registerpost = function registerpost(req, res) {
localGb = 0.0;
return monorail.request_node_get(userEntry.uuid).
then(function (result) {
if (!JSON.parse(result).name) {
if (!JSON.parse(result).hasOwnProperty('name')) {
var error = { error_message: { message: 'failed to find required node in RackHD' } };
logger.error(error);
throw error;
@ -349,16 +349,16 @@ module.exports.registerpost = function registerpost(req, res) {
throw error;
}
propreties = {
'cpus': dmiData.cpus,
'memory_mb': dmiData.memory,
'local_gb': localGb
cpus: dmiData.cpus,
memory_mb: dmiData.memory,
local_gb: localGb
};
node = {
'name': userEntry.uuid,
'driver': userEntry.driver,
'driver_info': info,
'properties': propreties,
'extra': extra
name: userEntry.uuid,
driver: userEntry.driver,
driver_info: info,
properties: propreties,
extra: extra
};
return keystone.authenticatePassword(ironicConfig.os_tenant_name, ironicConfig.os_username,
ironicConfig.os_password);
@ -373,10 +373,13 @@ module.exports.registerpost = function registerpost(req, res) {
throw JSON.parse(ret);
}
ironicNode = JSON.parse(ret);
port = { 'address': userEntry.port, 'node_uuid': ironicNode.uuid };
return ironic.createPort(ironicToken, JSON.stringify(port));
port = { address: userEntry.port, node_uuid: ironicNode.uuid };
return ironic.create_port(ironicToken, JSON.stringify(port));
}).
then(function (createPort) {
if (createPort && JSON.parse(createPort).error_message) {
throw JSON.parse(createPort);
}
logger.info('\r\nCreate port:\r\n' + JSON.stringify(createPort));
return ironic.set_power_state(ironicToken, ironicNode.uuid, 'on');
}).
@ -392,7 +395,7 @@ module.exports.registerpost = function registerpost(req, res) {
timer.stop = false;
timer.timeInterval = 15000;
timer.isDone = true;
var data = [{ 'path': '/extra/timer', 'value': timer, 'op': 'replace' }];
var data = [{ path: '/extra/timer', value: timer, op: 'replace' }];
return ironic.patch_node(ironicToken, ironicNode.uuid, JSON.stringify(data));
}).
then(function (result) {
@ -637,8 +640,8 @@ module.exports.deployOS = function deployOS(req, res) {
res.setHeader('Content-Type', 'application/json');
return monorail.runWorkFlow(req.swagger.params.identifier.value,
req.body.name,req.body)
.then(function(data) {
res.end(data);
.then(function(result) {
res.end(result);
})
.catch(function(err) {
res.end(JSON.stringify(err));
@ -654,12 +657,79 @@ module.exports.workflowStatus = function workflowStatus(req,res) {
return monorail.getWorkFlowActive(req.swagger.params.identifier.value)
.then(function(data) {
if (data) {
res.end(JSON.stringify({'jobStatus':'Running'}));
res.end(JSON.stringify({jobStatus:'Running'}));
} else {
res.end(JSON.stringify({'jobStatus':'Currently there is no job running on this node'}));
res.end(JSON.stringify({jobStatus:'Currently there is no job running on this node'}));
}
})
.catch(function(err) {
res.end(JSON.stringify(err));
});
};
/*
* @api {put} /api/1.1/uploadFiles/filename / PUT /
* @apiDescription uploaded ansible playbook in tar form and extract it
*/
//Code for tar file uploads
// module.exports.uploadFiles = function uploadFiles(req, res) {
// 'use strict';
// res.setHeader('content-type', 'text/plain');
// var tar = require('tar');
// var extractor = tar.Extract({path: 'files/extract'})
// .on('error', function(err) {
// logger.error(err);
// res.status(500);
// res.end('error');
// })
// .on('end', function() {
// res.status(202);
// res.end('success');
// });
// var stream = require('stream');
// var bufferStream = new stream.PassThrough();
// bufferStream.end(new Buffer(req.swagger.params.playbook.value.buffer));
// bufferStream.pipe(extractor);
// };
/*
* @api {post} /api/1.1/runAnsible/{identifier} / POST /
* @apiDescription run uploaded ansible playbook in tar form and extract it
*/
module.exports.runAnsible = function runAnsible(req, res) {
'use strict';
res.setHeader('Content-Type', 'application/json');
var ansibleTask = {
friendlyName: req.body.name,
injectableName: 'Task.Ansible.' + req.body.name,
implementsTask: 'Task.Base.Ansible',
options: {
playbook: req.body.playbookPath,
vars : req.body.vars
},
properties: { }
};
var ansibleWorkflow = {
friendlyName: 'Graph ' + req.body.name,
injectableName: 'Graph.Ansible.' + req.body.name,
tasks : [
{
label: 'ansible-job',
taskName: 'Task.Ansible.' + req.body.name
}
]
};
return monorail.createTask(ansibleTask)
.then(function() {
return monorail.createWorkflow(ansibleWorkflow);
})
.then(function() {
return monorail.runWorkFlow(req.swagger.params.identifier.value,
'Graph.Ansible.' + req.body.name,null);
})
.then(function(result) {
res.end(result);
})
.catch(function(err) {
res.end(JSON.stringify(err));
});
};

View File

@ -1,7 +1,7 @@
/* global process */
'use strict';
var app = require('connect')();
var app = require('express')();
var http = require('http');
var swaggerTools = require('swagger-tools');
var config = require('./config.json');

View File

@ -50,7 +50,7 @@ var HttpClient = {
method: 'POST',
headers: {
'Content-type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
'Content-Length': Buffer.byteLength(msg.data),
'User-Agent': 'shovel-client'
}
@ -140,16 +140,16 @@ var HttpClient = {
method: 'PUT',
headers: {
'Content-type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
'Content-Length': Buffer.byteLength(msg.data),
'User-Agent': 'shovel-client'
}
};
/*Update the request header with special fields*/
if (Buffer.byteLength(msg.token)) {
options.headers['X-Auth-Token'] = msg.token;
}
if (Buffer.byteLength(JSON.stringify(msg.api))) {
options.headers[msg.api.name] = msg.api.version;
}
@ -159,8 +159,8 @@ var HttpClient = {
response.on('data', function (chunk) {
body += chunk;
});
response.on('error', function (err) {
var errorMessage = { errorMessage: { hostname: msg.host, message: err } };
response.on('error', function (e) {
var errorMessage = { errorMessage: { hostname: msg.host, message: e } };
output(errorMessage);
});
response.on('end', function () {
@ -174,7 +174,9 @@ var HttpClient = {
output(errorMessage);
});
if (Buffer.byteLength(msg.data)) {
request.write(msg.data);
}
request.end();
},
Patch: function (msg, output) {
@ -187,7 +189,7 @@ var HttpClient = {
method: 'PATCH',
headers: {
'Content-type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
'Content-Length': Buffer.byteLength(msg.data),
'User-Agent': 'shovel-client'
}

View File

@ -149,7 +149,9 @@ var MonorailWrapper = {
runWorkFlow: function runWorkFlow(hwaddr,graphName,content) {
'use strict';
request.path = pfx + '/nodes/' + hwaddr + '/workflows/?name=' + graphName;
if (content !== null) {
request.data = JSON.stringify(content);
}
return client.PostAsync(request);
},
getWorkFlowActive: function getWorkFlowActive(hwaddr) {
@ -161,6 +163,18 @@ var MonorailWrapper = {
'use strict';
request.path = pfx + '/nodes/' + hwaddr + '/workflows/active';
return client.DeleteAsync(request);
},
createTask: function createTask(content) {
'use strict';
request.path = pfx + '/workflows/tasks/';
request.data = JSON.stringify(content);
return client.PutAsync(request);
},
createWorkflow: function createWorkflow(content) {
'use strict';
request.path = pfx + '/workflows';
request.data = JSON.stringify(content);
return client.PutAsync(request);
}
};
module.exports = Object.create(MonorailWrapper);

View File

@ -13,8 +13,8 @@ var request = {
token: '',
data: '',
api: {
'name': 'X-OpenStack-Ironic-API-Version',
'version': '1.6'
name: 'X-OpenStack-Ironic-API-Version',
version: '1.6'
}
};

View File

@ -33,11 +33,11 @@ var KeystoneAuthentication = {
}
request.data = JSON.stringify(
{
'auth': {
'tenantName': tenantName,
'passwordCredentials': {
'username': username,
'password': decrypted
auth: {
tenantName: tenantName,
passwordCredentials: {
username: username,
password: decrypted
}
}
@ -49,10 +49,10 @@ var KeystoneAuthentication = {
'use strict';
request.data = JSON.stringify(
{
'auth': {
'tenantName': tenantName,
'token': {
'id': token
auth: {
tenantName: tenantName,
token: {
id: token
}
}
});

View File

@ -135,9 +135,9 @@ function Poller(timeInterval) {
nodeData.extra.events = lastEvent;
var data = [
{
'path': '/extra',
'value': nodeData.extra,
'op': 'replace'
path: '/extra',
value: nodeData.extra,
op: 'replace'
}];
return data;
})

View File

@ -9,7 +9,6 @@
"license": "Apache-2.0",
"dependencies": {
"bluebird": "3.1.1",
"connect": "^3.2.0",
"swagger-tools": "0.8.*",
"should": "~7.0.1",
"mocha": "^2.1.0",
@ -23,7 +22,9 @@
"istanbul": "0.4.1",
"nock": "3.6.0",
"eslint-config-openstack": "~1.2.3",
"eslint": "~1.10.3"
"eslint": "~1.10.3",
"express": "~4.13.4",
"tar": "~2.2.1"
},
"scripts": {
"start": "start shovel",

View File

@ -185,5 +185,19 @@ describe('****Monorail Lib****',function(){
done();
});
});
it('monorail.createWorkflow return data from monorail', function (done) {
return monorail.createWorkflow({})
.then(function (result) {
result.should.have.property('data');
done();
});
});
it('monorail.createTask return data from monorail', function (done) {
return monorail.createTask({})
.then(function (result) {
result.should.have.property('data');
done();
});
});
});
});

View File

@ -56,6 +56,8 @@ describe('****SHOVEL API Interface****', function () {
sinon.stub(monorail, 'get_catalog_data_by_source').returns(Promise.resolve(JSON.stringify(catalogSource[0])));
sinon.stub(monorail, 'runWorkFlow').returns(Promise.resolve('{"definition":{}}'));
getWorkflow = sinon.stub(monorail,'getWorkFlowActive');
sinon.stub(monorail, 'createTask').returns(Promise.resolve());
sinon.stub(monorail, 'createWorkflow').returns(Promise.resolve());
//glance
sinon.stub(glance, 'get_images').returns(Promise.resolve(JSON.stringify(glanceImages)));
//keystone
@ -83,6 +85,8 @@ describe('****SHOVEL API Interface****', function () {
monorail['get_catalog_data_by_source'].restore();
monorail['runWorkFlow'].restore();
monorail['getWorkFlowActive'].restore();
monorail['createTask'].restore();
monorail['createWorkflow'].restore();
//ironic
ironic['patch_node'].restore();
ironic['get_node_list'].restore();
@ -334,10 +338,8 @@ describe('****SHOVEL API Interface****', function () {
.send({"name": "Graph.InstallCentOS","options": { "defaults": {"obmServiceName": "ipmi-obm-service"}}})
.end(function (err, res) {
if (err) {
console.log('hey yo');
throw err;
}
console.log('hello' + res.text)
JSON.parse(res.text).should.have.property('definition');
done();
});
@ -350,11 +352,22 @@ describe('****SHOVEL API Interface****', function () {
if (err) {
throw err;
}
console.log(res.text);
JSON.parse(res.text).should.have.property('jobStatus');
done();
});
});
it('/api/1.1/run/ansible-playbook/{id} should return property definition', function (done) {
request(url)
.post('/api/1.1/run/ansible-playbook/123')
.send({"name": "Graph.Example","options": {}})
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('definition');
done();
});
});
it('/api/1.1/worflow-status/{identifier} should return property jobStatus even if no job is running', function (done) {
getWorkflow.returns(Promise.resolve());
request(url)
@ -379,10 +392,12 @@ describe('****SHOVEL API Interface****', function () {
var output = ({ error: 'error_message' });
sinon.stub(client, 'GetAsync').returns(Promise.reject(output));
sinon.stub(client, 'PostAsync').returns(Promise.reject(output));
sinon.stub(client, 'PutAsync').returns(Promise.reject(output));
});
after('teardown mocks', function () {
client['GetAsync'].restore();
client['PostAsync'].restore();
client['PutAsync'].restore();
});
it('/api/1.1/nodes/identifier should return error message', function (done) {
@ -558,34 +573,92 @@ describe('****SHOVEL API Interface****', function () {
done();
});
});
it('api/1.1/run/ansible-playbook/{id} should return error message', function (done) {
request(url)
.post('/api/1.1/run/ansible-playbook/123')
.send({name: 'runExample',vars: {},
playbookPath: 'main.yml'
})
.expect(200)
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('error');
done();
});
});
});
describe('Shovel api unit test for register', function () {
var error_message = '{"error_message": "{\\"debuginfo\\": null, \\"faultcode\\": \\"Client\\", \\"faultstring\\": \\"A node with name 5668b42d8bee16a10989e4e4 already exists.\\"}"}';
var error_message = '{"error_message": "{\\"debuginfo\\": null, \\"faultcode\\": \\"Client\\", \\"faultstring\\": \\"some error\\"}"}';
var body = { "id": identifier, "driver": "string", "ipmihost": "string", "ipmiusername": "string", "ipmipasswd": "string" };
var getNode, diskSize, memoryCpu, ironicNodeCreate,
ironicCreatePort, ironicPowerState, ironicPatch;
beforeEach('set up mocks', function () {
//monorail
getNode = sinon.stub(monorail, 'request_node_get');
diskSize = sinon.stub(monorail, 'nodeDiskSize');
memoryCpu = sinon.stub(monorail, 'getNodeMemoryCpu');
monorailWhiteList = sinon.stub(monorail,'request_whitelist_set');
//keystone
sinon.stub(keystone, 'authenticatePassword').returns(Promise.resolve(JSON.stringify(keyToken)));
//ironic
sinon.stub(ironic, 'create_node').returns(Promise.resolve(error_message));
ironicNodeCreate = sinon.stub(ironic, 'create_node');
ironicCreatePort = sinon.stub(ironic,'create_port');
ironicPowerState = sinon.stub(ironic,'set_power_state');
ironicPatch = sinon.stub(ironic, 'patch_node');
});
afterEach('teardown mocks', function () {
//monorail
monorail['nodeDiskSize'].restore();
monorail['getNodeMemoryCpu'].restore();
monorail['request_node_get'].restore();
monorail['request_whitelist_set'].restore();
//keystone
keystone['authenticatePassword'].restore();
//ironic
ironic['create_node'].restore();
ironic['create_port'].restore();
ironic['set_power_state'].restore();
ironic['patch_node'].restore();
});
it('response in register should have property error_message when node returns empty ', function (done) {
getNode.returns(Promise.resolve('{}'));
request(url)
.post('/api/1.1/register')
.send(body)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('error_message');
done();
});
});
it('response in register should have property error_message when diskSize has an exception ', function (done) {
var output = {error_message: { message: 'failed to get compute node Disk Size' }};
getNode.returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
diskSize.returns(Promise.reject(output));
request(url)
.post('/api/1.1/register')
.send(body)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('error_message');
done();
});
});
it('response in register should have property error_message when any of node info equal to 0 ', function (done) {
sinon.stub(monorail, 'request_node_get').returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
sinon.stub(monorail, 'nodeDiskSize').returns(Promise.resolve(0));
sinon.stub(monorail, 'getNodeMemoryCpu').returns(Promise.resolve({ cpus: 0, memory: 0 }));
getNode.returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
diskSize.returns(Promise.resolve(0));
memoryCpu.returns(Promise.resolve({ cpus: 0, memory: 0 }));
request(url)
.post('/api/1.1/register')
@ -601,10 +674,10 @@ describe('****SHOVEL API Interface****', function () {
});
});
it('response in register should have property error_message create node return error in ironic', function (done) {
sinon.stub(monorail, 'request_node_get').returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
sinon.stub(monorail, 'nodeDiskSize').returns(Promise.resolve(1));
sinon.stub(monorail, 'getNodeMemoryCpu').returns(Promise.resolve({ cpus: 1, memory: 1 }));
getNode.returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
diskSize.returns(Promise.resolve(1));
memoryCpu.returns(Promise.resolve({ cpus: 1, memory: 1 }));
ironicNodeCreate.returns(Promise.resolve(error_message));
request(url)
.post('/api/1.1/register')
.send(body)
@ -618,5 +691,86 @@ describe('****SHOVEL API Interface****', function () {
done();
});
});
it('response in register should have property error_message create port return error in ironic', function (done) {
getNode.returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
diskSize.returns(Promise.resolve(1));
memoryCpu.returns(Promise.resolve({ cpus: 1, memory: 1 }));
ironicNodeCreate.returns(Promise.resolve(JSON.stringify(ironic_node_list[0])));
ironicCreatePort.returns(Promise.resolve(error_message));
request(url)
.post('/api/1.1/register')
.send(body)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('error_message');
done();
});
});
it('response in register should have property error_message set power state return error in ironic', function (done) {
getNode.returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
diskSize.returns(Promise.resolve(1));
memoryCpu.returns(Promise.resolve({ cpus: 1, memory: 1 }));
ironicNodeCreate.returns(Promise.resolve(JSON.stringify(ironic_node_list[0])));
ironicCreatePort.returns(Promise.resolve());
ironicPowerState.returns(Promise.resolve(error_message));
request(url)
.post('/api/1.1/register')
.send(body)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('error_message');
done();
});
});
it('response in register should have property error_message ironic patch node return error in ironic', function (done) {
getNode.returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
diskSize.returns(Promise.resolve(1));
memoryCpu.returns(Promise.resolve({ cpus: 1, memory: 1 }));
ironicNodeCreate.returns(Promise.resolve(JSON.stringify(ironic_node_list[0])));
ironicCreatePort.returns(Promise.resolve());
ironicPowerState.returns(Promise.resolve());
ironicPatch.returns(Promise.reject({error_message:'some error'}));
request(url)
.post('/api/1.1/register')
.send(body)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('error_message');
done();
});
});
it('response in register should have property result on success', function (done) {
getNode.returns(Promise.resolve(JSON.stringify(rackhdNode[0])));
diskSize.returns(Promise.resolve(1));
memoryCpu.returns(Promise.resolve({ cpus: 1, memory: 1 }));
ironicNodeCreate.returns(Promise.resolve(JSON.stringify(ironic_node_list[0])));
ironicCreatePort.returns(Promise.resolve());
ironicPowerState.returns(Promise.resolve());
ironicPatch.returns(Promise.resolve());
request(url)
.post('/api/1.1/register')
.send(body)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
if (err) {
throw err;
}
JSON.parse(res.text).should.have.property('result');
done();
});
});
});
});

View File

@ -1,6 +1,6 @@
// Copyright 2015, EMC, Inc.
var app = require('connect')();
var app = require('express')();
var http = require('http');
var swaggerTools = require('swagger-tools');
var sinon = require('sinon');