Add Mistral API, Nodes Introspection workflow

The mistral API service has been added which supports executing
either Mistral workflow or action

Added Introspect nodes action to Registered Nodes table
When the workflow is running, poll for it to update table and
monitor the workflow state.

Change-Id: I0c1cc05c266ba60e8b9559a78b71cb8bc6ebbe02
This commit is contained in:
Jiri Tomasek 2016-01-26 16:54:53 +01:00
parent b551384f46
commit f28fb31af9
16 changed files with 258 additions and 114 deletions

View File

@ -172,6 +172,9 @@ By running ```gulp serve``` (or ```gulp``` as a shortcut), karma server is also
- make sure you don't push those changes to ```karma.conf.js``` and ```package.json``` as part of your patch
## Documentation
Use JSDoc docstrings in code to provide source for autogenerated documentation (http://usejsdoc.org/).
## Basic OpenStack API Usage

View File

@ -57,9 +57,7 @@
"react-addons-test-utils": "~0.14.1",
"karma-junit-reporter": "~0.3.8",
"ini": "^1.3.4",
"redux-logger": "~2.5.0",
"redux-mock-store": "0.0.6",
"nock": "~7.0.2"
"redux-logger": "~2.5.0"
},
"scripts": {
"test": "karma start --single-run",

View File

@ -1,7 +1,3 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import nock from 'nock';
import NodesActions from '../../js/actions/NodesActions';
import NodesConstants from '../../js/constants/NodesConstants';
@ -10,9 +6,6 @@ const mockGetNodesResponse = [
{ uuid: 2 }
];
const middlewares = [ thunk ];
const mockStore = configureMockStore(middlewares);
describe('Nodes Actions', () => {
it('creates action to request nodes', () => {
const expectedAction = {
@ -28,26 +21,32 @@ describe('Nodes Actions', () => {
};
expect(NodesActions.receiveNodes(mockGetNodesResponse)).toEqual(expectedAction);
});
});
describe('Asynchronous Nodes Actions', () => {
afterEach(() => { nock.cleanAll(); });
it('creates an action to fetch nodes', (done) => {
nock(/.*:6385/)
.get('/nodes')
.reply(200, mockGetNodesResponse);
nock(/.*:6385/)
.get('/nodes/1')
.reply(200, mockGetNodesResponse[0]);
nock(/.*:6385/)
.get('/nodes/2')
.reply(200, mockGetNodesResponse[1]);
it('creates action to notify that nodes operation started', () => {
const expectedAction = {
type: NodesConstants.START_NODES_OPERATION
};
expect(NodesActions.startOperation(mockGetNodesResponse)).toEqual(expectedAction);
});
const expectedActions = [
{ type: NodesActions.REQUEST_NODES },
{ type: NodesActions.RECEIVE_NODES, payload: mockGetNodesResponse }
];
const store = mockStore({ nodes: [] }, expectedActions, done);
store.dispatch(NodesActions.fetchNodes());
it('creates action to notify that nodes operation finished', () => {
const expectedAction = {
type: NodesConstants.FINISH_NODES_OPERATION
};
expect(NodesActions.finishOperation(mockGetNodesResponse)).toEqual(expectedAction);
});
it('creates action to notify that nodes operation started', () => {
const expectedAction = {
type: NodesConstants.START_NODES_OPERATION
};
expect(NodesActions.startOperation(mockGetNodesResponse)).toEqual(expectedAction);
});
it('creates action to notify that nodes operation finished', () => {
const expectedAction = {
type: NodesConstants.FINISH_NODES_OPERATION
};
expect(NodesActions.finishOperation(mockGetNodesResponse)).toEqual(expectedAction);
});
});

View File

@ -2,10 +2,24 @@ import when from 'when';
import IronicApiErrorHandler from '../services/IronicApiErrorHandler';
import IronicApiService from '../services/IronicApiService';
import MistralApiService from '../services/MistralApiService';
import MistralApiErrorHandler from '../services/MistralApiErrorHandler';
import NodesConstants from '../constants/NodesConstants';
import NotificationActions from './NotificationActions';
export default {
startOperation(workflowId) {
return {
type: NodesConstants.START_NODES_OPERATION
};
},
finishOperation() {
return {
type: NodesConstants.FINISH_NODES_OPERATION
};
},
requestNodes() {
return {
type: NodesConstants.REQUEST_NODES
@ -37,5 +51,51 @@ export default {
});
});
};
},
introspectNodes() {
return (dispatch, getState) => {
dispatch(this.startOperation());
MistralApiService.runWorkflow('tripleo.baremetal.bulk_introspect').then((response) => {
if(response.state === 'ERROR') {
NotificationActions.notify({ title: 'Error', message: response.state_info });
dispatch(this.finishOperation());
} else {
dispatch(this.pollForIntrospectionWorkflow(response.id));
}
}).catch((error) => {
let errorHandler = new MistralApiErrorHandler(error);
errorHandler.errors.forEach((error) => {
NotificationActions.notify(error);
});
dispatch(this.finishOperation());
});
};
},
pollForIntrospectionWorkflow(workflowExecutionId) {
return (dispatch, getState) => {
MistralApiService.getWorkflowExecution(workflowExecutionId).then((response) => {
if(response.state === 'RUNNING') {
dispatch(this.fetchNodes());
setTimeout(() => dispatch(this.pollForIntrospectionWorkflow(workflowExecutionId)), 7000);
} else if(response.state === 'ERROR') {
NotificationActions.notify({ title: 'Error', message: response.state_info });
dispatch(this.finishOperation());
} else {
dispatch(this.finishOperation());
NotificationActions.notify({ type: 'success',
title: 'Introspection finished',
message: 'Nodes Introspection successfully finished' });
}
}).catch((error) => {
let errorHandler = new MistralApiErrorHandler(error);
errorHandler.errors.forEach((error) => {
NotificationActions.notify(error);
});
dispatch(this.finishOperation());
});
};
}
};

View File

@ -6,7 +6,8 @@ import NodesTable from './NodesTable';
export default class IntrospectedNodesTabPane extends React.Component {
render() {
return (
<NodesTable data={this.props.nodes.get('introspected')}/>
<NodesTable data={this.props.nodes.get('introspected')}
dataOperationInProgress={this.props.nodes.get('dataOperationInProgress')}/>
);
}
}

View File

@ -6,7 +6,8 @@ import NodesTable from './NodesTable';
export default class MaintenanceNodesTabPane extends React.Component {
render() {
return (
<NodesTable data={this.props.nodes.get('maintenance')}/>
<NodesTable data={this.props.nodes.get('maintenance')}
dataOperationInProgress={this.props.nodes.get('dataOperationInProgress')}/>
);
}
}

View File

@ -17,6 +17,10 @@ class Nodes extends React.Component {
this.props.dispatch(NodesActions.fetchNodes());
}
introspectNodes() {
this.props.dispatch(NodesActions.introspectNodes());
}
render() {
return (
<div className="row">
@ -49,7 +53,9 @@ class Nodes extends React.Component {
</NavTab>
</ul>
<div className="tab-pane">
{React.cloneElement(this.props.children, {nodes: this.props.nodes})}
{React.cloneElement(this.props.children,
{ nodes: this.props.nodes,
introspectNodes: this.introspectNodes.bind(this) })}
</div>
<div className="panel panel-info">

View File

@ -6,7 +6,8 @@ import NodesTable from './NodesTable';
export default class ProvisionedNodesTabPane extends React.Component {
render() {
return (
<NodesTable data={this.props.nodes.get('provisioned')}/>
<NodesTable data={this.props.nodes.get('provisioned')}
dataOperationInProgress={this.props.nodes.get('dataOperationInProgress')}/>
);
}
}

View File

@ -4,10 +4,29 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import NodesTable from './NodesTable';
export default class RegisteredNodesTabPane extends React.Component {
getTableActions() {
const dataOperationInProgress = this.props.nodes.get('dataOperationInProgress');
return (
<div className="btn-group">
<button className="btn btn-default"
type="button"
disabled={dataOperationInProgress}
onClick={e => {
e.preventDefault();
this.props.introspectNodes();
}}>
Introspect Nodes
</button>
</div>
);
}
render() {
return (
<div>
<NodesTable data={this.props.nodes.get('registered')}/>
<NodesTable data={this.props.nodes.get('registered')}
dataOperationInProgress={this.props.nodes.get('dataOperationInProgress')}
tableActions={this.getTableActions.bind(this)}/>
{this.props.children}
</div>
);
@ -15,5 +34,6 @@ export default class RegisteredNodesTabPane extends React.Component {
}
RegisteredNodesTabPane.propTypes = {
children: React.PropTypes.node,
introspectNodes: React.PropTypes.func,
nodes: ImmutablePropTypes.map
};

View File

@ -2,6 +2,7 @@ import React from 'react';
import invariant from 'invariant';
import DataTableRow from './DataTableRow';
import Loader from '../Loader';
export default class DataTable extends React.Component {
_getColumns() {
@ -43,11 +44,7 @@ export default class DataTable extends React.Component {
renderTableActions() {
if(this.props.tableActions) {
return (
<div className="dataTables_actions">
{this.props.tableActions()}
</div>
);
return this.props.tableActions();
}
}
@ -69,7 +66,12 @@ export default class DataTable extends React.Component {
<div className="dataTables_wrapper">
<div className="dataTables_header">
{this.renderFilterInput()}
{this.renderTableActions()}
<div className="dataTables_actions">
<Loader loaded={!this.props.dataOperationInProgress}
size="sm"
inline/>
{this.renderTableActions()}
</div>
<div className="dataTables_info">
Showing <b>{rows.length}</b> of <b>{this.props.data.length}</b> items
</div>
@ -96,6 +98,7 @@ DataTable.propTypes = {
React.PropTypes.node
]),
data: React.PropTypes.array.isRequired,
dataOperationInProgress: React.PropTypes.bool.isRequired,
filterString: React.PropTypes.string,
noRowsRenderer: React.PropTypes.func.isRequired,
onFilter: React.PropTypes.func,
@ -103,5 +106,6 @@ DataTable.propTypes = {
tableActions: React.PropTypes.func
};
DataTable.defaultProps = {
className: 'table'
className: 'table',
dataOperationInProgress: false
};

View File

@ -2,5 +2,7 @@ import keyMirror from 'keymirror';
export default keyMirror({
REQUEST_NODES: null,
RECEIVE_NODES: null
RECEIVE_NODES: null,
START_NODES_OPERATION: null,
FINISH_NODES_OPERATION: null
});

View File

@ -4,12 +4,14 @@ import NodesConstants from '../constants/NodesConstants';
const initialState = Map({
isFetching: false,
dataOperationInProgress: false,
allFilter: '',
registeredFilter: '',
introspectedFilter: '',
provisionedFilter: '',
maintenanceFilter: '',
all: List()
all: List(),
dataOperationInProgress: false
});
export default function nodesReducer(state = initialState, action) {
@ -23,6 +25,12 @@ export default function nodesReducer(state = initialState, action) {
.set('all', List(action.payload))
.set('isFetching', false);
case NodesConstants.START_NODES_OPERATION:
return state.set('dataOperationInProgress', true);
case NodesConstants.FINISH_NODES_OPERATION:
return state.set('dataOperationInProgress', false);
default:
return state;

View File

@ -0,0 +1,31 @@
import BaseHttpRequestErrorHandler from '../components/utils/BaseHttpRequestErrorHandler';
export default class MistralApiErrorHandler extends BaseHttpRequestErrorHandler {
_generateErrors(xmlHttpRequestError) {
let errors = [];
let error;
switch(xmlHttpRequestError.status) {
case 0:
errors.push({
title: 'Connection Error',
message: 'Connection to Mistral API is not available'
});
break;
case 401:
error = JSON.parse(xmlHttpRequestError.responseText).error;
errors.push({
title: 'Unauthorized',
message: error.message
});
break;
default:
error = JSON.parse(xmlHttpRequestError.responseText);
errors.push({
title: xmlHttpRequestError.statusText,
message: error.faultstring
});
break;
}
return errors;
}
}

View File

@ -0,0 +1,76 @@
import * as _ from 'lodash';
import request from 'reqwest';
import when from 'when';
import TempStorage from './TempStorage';
import LoginStore from '../stores/LoginStore';
class MistralApiService {
defaultRequest(additionalAttributes) {
return _.merge({
headers: {
'X-Auth-Token': TempStorage.getItem('keystoneAuthTokenId')
},
crossOrigin: true,
contentType: 'application/json',
type: 'json',
method: 'GET'
}, additionalAttributes);
}
/**
* Gets a Workflow execution
* Mistral API: GET /v2/executions/:execution_id
* @param {string} id - Workflow Execution ID
* @return {object} Execution.
*/
getWorkflowExecution(id) {
return when(request(this.defaultRequest(
{
url: LoginStore.getServiceUrl('mistral') + '/executions/' + id
}
)));
}
/**
* Starts a new Workflow execution
* Mistral API: POST /v2/executions
* @param {string} workflowName - Workflow name
* @param {object} input - Workflow input object
* @return {object} Execution.
*/
runWorkflow(workflowName, input = {}) {
return when(request(this.defaultRequest(
{
method: 'POST',
url: LoginStore.getServiceUrl('mistral') + '/executions',
data: JSON.stringify({
workflow_name: workflowName,
input: JSON.stringify(input)
})
}
)));
}
/**
* Starts a new Action execution
* Mistral API: POST /v2/action_executions
* @param {string} actionName - Name of the Action to be executed
* @param {object} input - Action input object
* @return {object} Action Execution.
*/
runAction(actionName, input = {}) {
return when(request(this.defaultRequest(
{
method: 'POST',
url: LoginStore.getServiceUrl('mistral') + '/action_executions',
data: JSON.stringify({
name: actionName,
input: JSON.stringify(input)
})
}
)));
}
}
export default new MistralApiService();

View File

@ -1,71 +0,0 @@
import BaseStore from './BaseStore';
import NodesConstants from '../constants/NodesConstants';
class NodesStore extends BaseStore {
constructor() {
super();
this.subscribe(() => this._registerToActions.bind(this));
this.state = {
nodes: {
all: [],
registered: [],
introspected: [],
provisioned: [],
maintenance: []
}
};
}
_registerToActions(payload) {
switch(payload.actionType) {
case NodesConstants.LIST_NODES:
this.onListNodes(payload.nodes);
break;
default:
break;
}
}
onListNodes(nodes) {
this.state.nodes.all = nodes;
this.state.nodes.registered = this._filterRegisteredNodes(nodes);
this.state.nodes.introspected = this._filterIntrospectedNodes(nodes);
this.state.nodes.provisioned = this._filterProvisionedNodes(nodes);
this.state.nodes.maintenance = this._filterMaintenanceNodes(nodes);
this.emitChange();
}
_filterRegisteredNodes(nodes) {
return nodes.filter((node) => {
return node.provision_state === 'available' &&
!node.provision_updated_at ||
node.provision_state === 'manageable';
// ??? return node.provision_state === 'enroll';
});
}
_filterIntrospectedNodes(nodes) {
return nodes.filter((node) => {
return node.provision_state === 'available' && !!node.provision_updated_at;
});
}
_filterProvisionedNodes(nodes) {
return nodes.filter((node) => {
return node.instance_uuid;
});
}
_filterMaintenanceNodes(nodes) {
return nodes.filter((node) => {
return node.maintenance;
});
}
getState() {
return this.state;
}
}
export default new NodesStore();

View File

@ -94,3 +94,8 @@
}
}
}
// spinner
.spinner.spinner-inline {
vertical-align: middle;
}