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:
parent
b551384f46
commit
f28fb31af9
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
@ -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')}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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')}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
|
@ -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();
|
|
@ -94,3 +94,8 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spinner
|
||||
.spinner.spinner-inline {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue