diff --git a/README.md b/README.md new file mode 100644 index 0000000..efe3f77 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# RackHD plugin for OpenStack Horizon dashboard + +- [Configure Openstack to Boot Baremetal nodes](https://github.com/keedya/Shovel-horizon/blob/master/Horizon/setup_openstack.md) +- git clone https://github.com/keedya/Shovel-horizon.git +- cd Shovel-horizon/Horizon +- sudo ./install.sh --url 'Shovel Url' --location 'Horizon Path' +- sudo service apache2 restart diff --git a/_50_admin_rackhd_panels.py b/_50_admin_rackhd_panels.py new file mode 100644 index 0000000..39dc754 --- /dev/null +++ b/_50_admin_rackhd_panels.py @@ -0,0 +1,11 @@ +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'rackhd' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'admin' + +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'admin' + +# Python panel class of the PANEL to be added. +ADD_PANEL = ('openstack_dashboard.dashboards.admin.' + 'rackhd.panel.Rackhd') \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..3e6879a --- /dev/null +++ b/install.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +TEMP=`getopt -o u:l: --long url: --long location: -- "$@"` + +if [ $? != 0 ] ; then echo "Exit" ; exit 1 ; fi + +eval set -- "$TEMP" +SHOVEL_URL=${SHOVEL_URL-} +FILE_LOC=${FILE_LOC-} + +while true ; do + case "$1" in + -u | --url) SHOVEL_URL=$2 ;shift 2 ;; + -l | --location) FILE_LOC=$2;shift 2 ;; + --) shift; break ;; + *) echo "Internal error!" ; exit 1 ;; + esac +done +echo "get shovel url: " $SHOVEL_URL +echo "get file location: " $FILE_LOC +if [ -z "$SHOVEL_URL" -o -z "$FILE_LOC" ] +then + echo "You must specify Shovel service URL(http://)using --url " + echo "and horizon location using --location " + exit 1 +fi + +#replace in shovel.py SHOVEL_URL with the new url value +sed -i "s|.*URI = .*|URI = \"$SHOVEL_URL\" + SHOVEL_BASE_API|g" rackhd/shovel.py +#copy rackhd to horizon admin dashboard +cp -r rackhd $FILE_LOC/openstack_dashboard/dashboards/admin +#copy _50_admin_rackhd_panels.py to dashboard enabled +cp _50_admin_rackhd_panels.py $FILE_LOC/openstack_dashboard/enabled diff --git a/rackhd/__init__.py b/rackhd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rackhd/forms.py b/rackhd/forms.py new file mode 100644 index 0000000..c768d62 --- /dev/null +++ b/rackhd/forms.py @@ -0,0 +1,152 @@ +# 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. + +import logging +from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.rackhd import shovel + +LOG = logging.getLogger(__name__) + +class RegisterForm(forms.SelfHandlingForm): + uuid = forms.Field(label=_('Node ID'), widget=forms.TextInput(attrs={'readonly':'readonly'})) + name = forms.CharField(max_length=255, label=_('Name'), required=True) + driver = forms.ChoiceField(label=_('Driver'), required=True, + widget=forms.Select(attrs={'class': 'switchable','data-slug': 'driver'})) + kernel = forms.ChoiceField(label=_('Deploy Kernel'), required=True, + widget=forms.Select(attrs={'class': 'switchable'})) + ramdisk = forms.ChoiceField(label=_('Deploy RAM Disk'), required=True, + widget=forms.Select(attrs={'class': 'switchable'})) + port = forms.ChoiceField(label=_('Mac address'), required=True, + widget=forms.Select(attrs={'class': 'switchable'})) + ipmihost = forms.CharField(required=False, + widget=forms.TextInput(attrs={'class': 'switched','data-switch-on': 'driver','data-driver-pxe_ipmitool': _('IPMI Host Address')})) + ipmiuser = forms.CharField(required=False, + widget=forms.TextInput(attrs={'class': 'switched','data-switch-on': 'driver','data-driver-pxe_ipmitool': _('IPMI Username')})) + ipmipass = forms.CharField(required=False, + widget=forms.PasswordInput(attrs={'class': 'switched','data-switch-on': 'driver','data-driver-pxe_ipmitool': _('IPMI Password')})) + + sshhost = forms.CharField(required=False, + widget=forms.TextInput(attrs={'class': 'switched','data-switch-on': 'driver','data-driver-pxe_ssh': _('SSH Host Address')})) + sshuser = forms.CharField(required=False, + widget=forms.TextInput(attrs={'class': 'switched','data-switch-on': 'driver','data-driver-pxe_ssh': _('SSH Username')})) + sshpass = forms.CharField(required=False, + widget=forms.PasswordInput(attrs={'class': 'switched','data-switch-on': 'driver','data-driver-pxe_ssh': _('SSH Password')})) + sshport = forms.CharField(required=False, + widget=forms.TextInput(attrs={'class': 'switched','data-switch-on': 'driver','data-driver-pxe_ssh': _('SSH Port')})) + + failovernode = forms.ChoiceField(label=_("Failover Node"), required=False) + enfailover = forms.BooleanField(label=_("Enable Failover"), initial=False, required=False) + eventre = forms.CharField(max_length=255, label=_('Event Trigger (regex)'), required=False, initial='') + + def __init__(self, request, *args, **kwargs): + super(RegisterForm, self).__init__(request, *args, **kwargs) + self._node = kwargs['initial'].get('node', None) + if self._node is not None: + self._drivers = kwargs['initial']['drivers'] + self._ramdisk = kwargs['initial']['images'] + self._macaddress = kwargs['initial']['ports'] + self.fields['name'].initial = shovel.get_catalog_data_by_source(self._node['id'],'dmi')['System Information']['Product Name'] + self.fields['uuid'].initial = self._node['id'] + self.fields['driver'].choices = [ (elem,_(elem)) for elem in self._drivers ] + self.fields['kernel'].choices = [ (elem,_(elem)) for elem in self._ramdisk ] + self.fields['ramdisk'].choices = [ (elem,_(elem)) for elem in self._ramdisk ] + self.fields['port'].choices = [ (elem,_(elem)) for elem in self._macaddress] + # BMC information initials + bmc = shovel.get_catalog_data_by_source(self._node['id'], 'bmc') + bmcuser = shovel.get_catalog_data_by_source(self._node['id'], 'ipmi-user-list-1') + self.fields['ipmihost'].initial = bmc['IP Address'] + self.fields['ipmiuser'].initial = bmcuser['2'][''] + + # Host network initials + host = shovel.get_catalog_data_by_source(self._node['id'], 'ohai') + self.fields['sshuser'].initial = host['current_user'] + self.fields['sshhost'].initial = host['ipaddress'] + self.fields['sshport'].initial = '22' + + # Failover node initials + nodes = shovel.request_nodes_get() + self.fields['failovernode'].choices = [ (n['id'],_(n['id'])) for n in nodes if n['id'] != self._node['id'] ] + else: + redirect = reverse('horizon:admin:rackhd:index') + msg = 'Invalid node ID specified' + messages.error(request, _(msg)) + raise ValueError(msg) + + def _add_new_node(self, request, data): + try: + # create node with shovel + #replace kernal and ramdisk with image id + list_images = shovel.get_images_list()['images'] + for elem in list_images: + if data['ramdisk'] == elem['name']: + data['ramdisk'] = elem['id'] + if data['kernel'] == elem['name']: + data['kernel'] = elem['id'] + result = shovel.register_node_post(data) + if 'error_message' in result: + raise Exception(result) + else: + msg = _('Registered node {0} ({1})'.format(data['uuid'], data['name'])) + messages.success(request, msg) + return True + except Exception: + redirect = reverse('horizon:admin:rackhd:index') + msg = _('Failed to register baremetal node: {0} ({1})'.format(data['uuid'], data['name'])) + messages.error(request, msg) + return False + + def _check_failover(self, data): + if not data['enfailover']: + data.pop('failovernode', None) + + def handle(self, request, data): + self._check_failover(data) + self._add_new_node(request, data) + return True + + +class UnregisterForm(forms.SelfHandlingForm): + uuid = forms.CharField(max_length=255, label=_("Unregister Node")) + + def __init__(self, request, *args, **kwargs): + super(UnregisterForm, self).__init__(request, *args, **kwargs) + self._node = kwargs['initial']['node'] + self.fields['uuid'].initial = self._node['id'] + + def _remove_current_node(self, request, data): + try: + # unregister a node from ironic using shovel + result = shovel.unregister_node_del(data['uuid']) + if 'result' in result: + msg = _('Unregistered node {0}'.format(data['uuid'])) + messages.success(request, msg) + return True + else: + raise Exception(result) + except Exception: + redirect = reverse('horizon:admin:rackhd:index') + msg = _('Failed to unregister baremetal node: {0}'.format(data['uuid'])) + messages.error(request, msg) + return False + + def handle(self, request, data): + self._remove_current_node(request, data) + return True + diff --git a/rackhd/json2html.py b/rackhd/json2html.py new file mode 100644 index 0000000..64d3b63 --- /dev/null +++ b/rackhd/json2html.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- + +''' +JSON 2 HTML convertor +===================== +(c) Varun Malhotra 2013 +Source Code: https://github.com/softvar/json2html +Contributors: +------------- +1. Michel Müller(@muellermichel), patch #2 - https://github.com/softvar/json2html/pull/2 +LICENSE: MIT +-------- +''' + +import json +import ordereddict + +class JSON: + + def convert(self, **args): + ''' + convert json Object to HTML Table format + ''' + + # table attributes such as class + # eg: table_attributes = "class = 'sortable table table-condensed table-bordered table-hover' + global table_attributes + table_attributes = '' + + if 'table_attributes' in args: + table_attributes = args['table_attributes'] + else: + # by default HTML table border + table_attributes = 'border="1"' + + if 'json' in args: + self.json_input = args['json'] + try: + json.loads(self.json_input) + except: + self.json_input = json.dumps(self.json_input) + else: + raise Exception('Can\'t convert NULL!') + + + ordered_json = json.loads(self.json_input, object_pairs_hook=ordereddict.OrderedDict) + + return self.iterJson(ordered_json) + + + def columnHeadersFromListOfDicts(self, ordered_json): + ''' + If suppose some key has array of objects and all the keys are same, + instead of creating a new row for each such entry, club those values, + thus it makes more sense and more readable code. + @example: + jsonObject = {"sampleData": [ {"a":1, "b":2, "c":3}, {"a":5, "b":6, "c":7} ] } + OUTPUT: +
1
acb
132
576
+ @contributed by: @muellermichel + ''' + + if len(ordered_json) < 2: + return None + if not isinstance(ordered_json[0],dict): + return None + + column_headers = ordered_json[0].keys() + + for entry in ordered_json: + if not isinstance(entry,dict): + return None + if len(entry.keys()) != len(column_headers): + return None + for header in column_headers: + if not header in entry: + return None + return column_headers + + + def iterJson(self, ordered_json): + ''' + Iterate over the JSON and process it to generate the super awesome HTML Table format + ''' + + def markup(entry, parent_is_list = False): + ''' + Check for each value corresponding to its key and return accordingly + ''' + if(isinstance(entry,unicode)): + return unicode(entry) + if(isinstance(entry,int) or isinstance(entry,float)): + return str(entry) + if(parent_is_list and isinstance(entry,list)==True): + #list of lists are not accepted + return '' + if(isinstance(entry,list)==True) and len(entry) == 0: + return '' + if(isinstance(entry,list)==True): + return '
  • ' + '
  • '.join([markup(child, parent_is_list=True) for child in entry]) + '
' + if(isinstance(entry,dict)==True): + return self.iterJson(entry) + + #safety: don't do recursion over anything that we don't know about - iteritems() will most probably fail + return '' + + convertedOutput = '' + + global table_attributes + table_init_markup = "" %(table_attributes) + convertedOutput = convertedOutput + table_init_markup + + for k,v in ordered_json.iteritems(): + convertedOutput = convertedOutput + '' + convertedOutput = convertedOutput + '' + + if (v == None): + v = unicode("") + if(isinstance(v,list)): + column_headers = self.columnHeadersFromListOfDicts(v) + if column_headers != None: + convertedOutput = convertedOutput + '' + for list_entry in v: + convertedOutput = convertedOutput + '' + convertedOutput = convertedOutput + '
'+ str(k) +'' + convertedOutput = convertedOutput + table_init_markup + convertedOutput = convertedOutput + '
' + ''.join(column_headers) + '
' + ''.join([markup(list_entry[column_header]) for column_header in column_headers]) + '
' + convertedOutput = convertedOutput + '' + convertedOutput = convertedOutput + '' + continue + convertedOutput = convertedOutput + '' + markup(v) + '' + convertedOutput = convertedOutput + '' + convertedOutput = convertedOutput + '' + return convertedOutput + +json2html = JSON() \ No newline at end of file diff --git a/rackhd/panel.py b/rackhd/panel.py new file mode 100644 index 0000000..6ff4dc7 --- /dev/null +++ b/rackhd/panel.py @@ -0,0 +1,19 @@ +# 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. + +from django.utils.translation import ugettext_lazy as _ + +import horizon +class Rackhd(horizon.Panel): + name = _("RackHD") + slug = "rackhd" + permissions = ('openstack.roles.admin',) \ No newline at end of file diff --git a/rackhd/shovel.py b/rackhd/shovel.py new file mode 100644 index 0000000..fd87dde --- /dev/null +++ b/rackhd/shovel.py @@ -0,0 +1,79 @@ +# 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. +import logging +import json +import requests + + +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions + +LOG = logging.getLogger(__name__) + +SHOVEL_BASE_API = '/api/1.1' +URI = SHOVEL_URL + SHOVEL_BASE_API + +def get_driver_list(): + r = requests.get(URI + '/ironic/drivers') + return r.json() + +def get_images_list(): + r = requests.get(URI + '/glance/images') + return r.json() + +def get_ironic_nodes(): + r = requests.get(URI + '/ironic/nodes') + return r.json() + +def get_ironic_node(id): + r = requests.get(URI + '/ironic/nodes/' + id) + return r.json() + +def request_node_get(id): + r = requests.get(URI + '/nodes/' + id) + return r.json() + +def request_nodes_get(): + r = requests.get(URI + '/nodes') + return r.json() + +def register_node_post(data): + headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} + r = requests.post(URI + '/register', + data=json.dumps(data), headers=headers) + return r.json() + +def unregister_node_del(name): + headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} + data = {} + r = requests.delete(URI + '/unregister/' + name, + data=json.dumps(data), headers=headers) + return r.json() + +def request_catalog_get(uuid): + r = requests.get(URI+ '/catalogs/' + uuid) + return r.json() + +def get_catalog_data_by_source(id,source): + r = requests.get(URI+ '/catalogs/' + id + '/' + source) + return r.json()['data'] + +def get_current_sel_data(id): + r = requests.get(URI+ '/nodes/' + id + '/sel') + return r.json() + +def node_patch(uuid, data): + headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} + r = requests.patch(URI + '/ironic/node/' + uuid, + data=json.dumps(data), headers=headers) + return r.json() diff --git a/rackhd/tables.py b/rackhd/tables.py new file mode 100644 index 0000000..fd416b0 --- /dev/null +++ b/rackhd/tables.py @@ -0,0 +1,127 @@ +# 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. + +from django.core.urlresolvers import reverse +from django.template import defaultfilters as filters +from django.utils.translation import pgettext_lazy +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from horizon import tables +from horizon.utils import filters as utils_filters + +from openstack_dashboard import api +from openstack_dashboard import policy + +class RegisterSelectedNodes(tables.LinkAction): + name = "register_selected" + verbose_name = _("Register Selected") + icon = "plus" + classes = ("ajax-modal",) + url = "horizon:admin:rackhd:register" + def get_link_url(self, datum=None, *args, **kwargs): + return reverse(self.url) + + +class UnregisterSelectedNodes(tables.LinkAction): + name = "unregister_selected" + verbose_name = _("Unegister Selected") + icon = "minus" + classes = ("ajax-modal",) + url = "horizon:admin:rackhd:unregister" + def get_link_url(self, datum=None, *args, **kwargs): + return reverse(self.url) + + +class RegisterNode(tables.LinkAction): + name = "register" + verbose_name = _("Register") + icon = "plus" + classes = ("ajax-modal",) + url = "horizon:admin:rackhd:register" + + +class Failover(tables.LinkAction): + name = "failover" + verbose_name = _("Failover") + icon = "minus" + classes = ("ajax-modal",) + url = "horizon:admin:rackhd:failover" + + +class UnregisterNode(tables.LinkAction): + name = "unregister" + verbose_name = _("Unregister") + icon = "minus" + classes = ("ajax-modal",) + url = "horizon:admin:rackhd:unregister" + + +class BareMetalFilterAction(tables.FilterAction): + def filter(self, table, services, filter_string): + q = filter_string.lower() + return filter(lambda service: q in service.host.lower(), services) + + +class BareMetalDetailsTable(tables.DataTable): + catalog = tables.Column("catalog", verbose_name=_("Node Catalog"), filters=[filters.safe]) + class Meta(object): + name = "node_catalog" + verbose_name = _("Catalog") + + +class BareMetalLastEventTable(tables.DataTable): + date = tables.Column('date',verbose_name=_("Date")) + event = tables.Column('event',verbose_name=_("Event")) + logid = tables.Column('logid',verbose_name=_("Log ID")) + sensor_num = tables.Column('sensor_num',verbose_name=_("Sensor Number")) + sensor_type = tables.Column('sensor_type',verbose_name=_("Sensor Type")) + time = tables.Column('time',verbose_name=_("Time")) + value = tables.Column('value',verbose_name=_("Value")) + class Meta(object): + name = "lastevent" + hidden_title=False + verbose_name = _("Last Triggered") + row_actions = (Failover,UnregisterNode,) + + +class BareMetalAllEventsTable(tables.DataTable): + if False: + date = tables.Column('date',verbose_name=_("Date")) + event = tables.Column('event',verbose_name=_("Event")) + logid = tables.Column('logid',verbose_name=_("Log ID")) + sensor_num = tables.Column('sensor_num',verbose_name=_("Sensor Number")) + sensor_type = tables.Column('sensor_type',verbose_name=_("Sensor Type")) + time = tables.Column('time',verbose_name=_("Time")) + value = tables.Column('value',verbose_name=_("Value")) + else: + html = tables.Column('html',verbose_name=_("System Events"), filters=[filters.safe]) + + class Meta(object): + name = "allevents" + hidden_title=False + verbose_name = _("All Events") + + +class BareMetalTable(tables.DataTable): + name = tables.Column('name', verbose_name=_('Name'), link="horizon:admin:rackhd:detail", ) + uuid = tables.Column('uuid', verbose_name=_('Node ID') ) + hwaddr = tables.Column('hwaddr', verbose_name=_('MAC Address') ) + events = tables.Column('events', verbose_name=_('Events'), link="horizon:admin:rackhd:events" ) + state = tables.Column('state', verbose_name=_('State')) + class Meta(object): + name = "baremetal" + verbose_name = _("Baremetal Compute Nodes") + table_actions = (BareMetalFilterAction,) + multi_select = False + row_actions = (RegisterNode, UnregisterNode,) + diff --git a/rackhd/templates/rackhd/_register.html b/rackhd/templates/rackhd/_register.html new file mode 100644 index 0000000..813308e --- /dev/null +++ b/rackhd/templates/rackhd/_register.html @@ -0,0 +1,25 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}register_form{% endblock %} +{% block form_action %}{% url 'horizon:admin:rackhd:register' baremetal %}{% endblock %} + +{% block modal-header %}{% trans "Register Node" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% trans "Register bare metal node." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/rackhd/templates/rackhd/_unregister.html b/rackhd/templates/rackhd/_unregister.html new file mode 100644 index 0000000..3574f6a --- /dev/null +++ b/rackhd/templates/rackhd/_unregister.html @@ -0,0 +1,25 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}unregister_form{% endblock %} +{% block form_action %}{% url 'horizon:admin:rackhd:unregister' baremetal %}{% endblock %} + +{% block modal-header %}{% trans "Unregister Node" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% trans "Unegister bare metal node." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/rackhd/templates/rackhd/detail.html b/rackhd/templates/rackhd/detail.html new file mode 100644 index 0000000..47d807d --- /dev/null +++ b/rackhd/templates/rackhd/detail.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Node Details" %}{% endblock %} + +{% block main %} +
+
+ {{ table.render }} +
+
+{% endblock %} diff --git a/rackhd/templates/rackhd/events.html b/rackhd/templates/rackhd/events.html new file mode 100644 index 0000000..92431fa --- /dev/null +++ b/rackhd/templates/rackhd/events.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Node Events" %}{% endblock %} + +{% block main %} +
+ {{ lastevent_table.render }} +
+
+ {{ allevents_table.render }} +
+{% endblock %} diff --git a/rackhd/templates/rackhd/index.html b/rackhd/templates/rackhd/index.html new file mode 100644 index 0000000..593c66a --- /dev/null +++ b/rackhd/templates/rackhd/index.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Bare Metal" %}{% endblock %} + +{% block main %} + {{ table.render }} +{% endblock %} diff --git a/rackhd/templates/rackhd/register.html b/rackhd/templates/rackhd/register.html new file mode 100644 index 0000000..e68ca42 --- /dev/null +++ b/rackhd/templates/rackhd/register.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Register" %}{% endblock %} + +{% block main %} + {% include 'admin/rackhd/_register.html' %} +{% endblock %} diff --git a/rackhd/templates/rackhd/unregister.html b/rackhd/templates/rackhd/unregister.html new file mode 100644 index 0000000..5dbd53e --- /dev/null +++ b/rackhd/templates/rackhd/unregister.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Unregister" %}{% endblock %} + +{% block main %} + {% include 'admin/rackhd/_unregister.html' %} +{% endblock %} diff --git a/rackhd/tests.py b/rackhd/tests.py new file mode 100644 index 0000000..b9d7be7 --- /dev/null +++ b/rackhd/tests.py @@ -0,0 +1,19 @@ +# 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. + +from horizon.test import helpers as test + + +class RackhdTests(test.TestCase): + # Unit tests for rackhd. + def test_me(self): + self.assertTrue(1 + 1 == 2) diff --git a/rackhd/urls.py b/rackhd/urls.py new file mode 100644 index 0000000..990ece9 --- /dev/null +++ b/rackhd/urls.py @@ -0,0 +1,28 @@ +# 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. + +from django.conf.urls import patterns +from django.conf.urls import url +from django.conf.urls import include + +from openstack_dashboard.dashboards.admin.rackhd import views + + +urlpatterns = patterns( + 'openstack_dashboard.dashboards.admin.rackhd.views', + url(r'^$', views.IndexView.as_view(), name='index'), + url(r'^(?P[^/]+)/register$', views.RegisterView.as_view(), name='register'), + url(r'^(?P[^/]+)/unregister$', views.UnregisterView.as_view(), name='unregister'), + url(r'^(?P[^/]+)/detail$', views.BareMetalDetailView.as_view(), name='detail'), + url(r'^(?P[^/]+)/events$', views.BareMetalEventView.as_view(), name='events'), + url(r'^(?P[^/]+)/failover$', views.FailoverView.as_view(), name='failover'), +) \ No newline at end of file diff --git a/rackhd/views.py b/rackhd/views.py new file mode 100644 index 0000000..9f83b5a --- /dev/null +++ b/rackhd/views.py @@ -0,0 +1,276 @@ +# 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. +import logging +import json +import pprint + +from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tables +from horizon import messages + +from openstack_dashboard import api + +from openstack_dashboard.dashboards.admin.rackhd \ + import forms as baremetal_forms +from openstack_dashboard.dashboards.admin.rackhd \ + import tables as baremetal_tables + +from openstack_dashboard.dashboards.admin.rackhd \ + import json2html as j2h + +from openstack_dashboard.dashboards.admin.rackhd import shovel + +LOG = logging.getLogger(__name__) + +class IndexView(tables.DataTableView): + # A very simple class-based view... + table_class = baremetal_tables.BareMetalTable + template_name = "admin/rackhd/index.html" + page_title = _("Baremetal") + + class NodeData: + def __init__(self, uuid, name, hwaddr, events, state): + self.id = uuid + self.name = name + self.uuid = uuid + self.hwaddr = hwaddr + self.events = events + self.state = state + + def get_data(self): + data = [] + try: + nodes = shovel.request_nodes_get() + i = 0 + for n in nodes: + dmi = shovel.get_catalog_data_by_source(n['id'],'dmi') + name = dmi['System Information']['Product Name'] + hwaddr = n['name'] + id = n['id'] + events = '0' + n = self._find_ironic_node(id) + if n is not None: + events = n['extra'].get('eventcnt','0') + state = 'Registered' + else: + state = 'Unregistered' + i += i +1 + data.append(self.NodeData(id, name, hwaddr, events, state)) + return data + except Exception, e: + print + LOG.error("Excepton in get_baremetal_data(): {0}".format(e)) + return data + def _find_ironic_node(self, id): + # ISSUE: iterating all nodes because query by name (onrack id) isn't working in ironic? + nodes = shovel.get_ironic_nodes() + for n in nodes['nodes']: + if n['extra'].get('nodeid', None) == id: + return n + return None + +class RegisterView(forms.ModalFormView): + context_object_name = 'baremetal' + template_name = 'admin/rackhd/register.html' + form_class = baremetal_forms.RegisterForm + success_url = reverse_lazy('horizon:admin:rackhd:index') + page_title = _("Register Node") + + def get_context_data(self, **kwargs): + context = super(RegisterView, self).get_context_data(**kwargs) + context["baremetal"] = self.kwargs['baremetal'] + return context + + def get_initial(self): + id = self.kwargs['baremetal'] + node = shovel.request_node_get(id) + list_drivers = shovel.get_driver_list()['drivers'] + drivers = [ elem['name'] for elem in list_drivers ] + ports = str(node['name']).split(',') + + list_images = shovel.get_images_list()['images'] + images = [ elem['name'] for elem in list_images ] + initial = super(RegisterView, self).get_initial() + initial.update({'nodeid': self.kwargs['baremetal'], 'node': node, 'drivers': drivers,'images':images,'ports': ports}) + return initial + + +class UnregisterView(forms.ModalFormView): + context_object_name = 'baremetal' + template_name = 'admin/rackhd/unregister.html' + form_class = baremetal_forms.UnregisterForm + success_url = reverse_lazy('horizon:admin:rackhd:index') + page_title = _("Unegister Node") + + def get_context_data(self, **kwargs): + context = super(UnregisterView, self).get_context_data(**kwargs) + context["baremetal"] = self.kwargs['baremetal'] + return context + + def get_initial(self): + id = self.kwargs['baremetal'] + node = shovel.request_node_get(id) + initial = super(UnregisterView, self).get_initial() + initial.update({'nodeid': self.kwargs['baremetal'], 'node': node}) + return initial + + +class FailoverView(forms.ModalFormView): + context_object_name = 'baremetal' + template_name = 'admin/rackhd/register.html' + form_class = baremetal_forms.RegisterForm + success_url = reverse_lazy('horizon:admin:rackhd:index') + page_title = _("Failover") + + def _find_ironic_node(self, id): + nodes = shovel.get_ironic_nodes() + for n in nodes['nodes']: + if n['extra'].get('nodeid', None) == id: + return n + + def _remove_node(self,id): + try: + result = shovel.unregister_node_del(id) + return True + except Exception: + redirect = reverse('horizon:admin:rackhd:index') + return False + + def get_context_data(self, **kwargs): + context = super(FailoverView, self).get_context_data(**kwargs) + context["baremetal"] = self.kwargs['baremetal'] + return context + + def get_initial(self): + initial = super(FailoverView, self).get_initial() + current_id = self.kwargs['baremetal'] + inode = self._find_ironic_node(current_id) + try: + if inode is not None: + failover = inode['extra'].get('failover', None) + if failover is not None: + node = shovel.request_node_get(failover) + list_drivers = shovel.get_driver_list()['drivers'] + drivers = [ elem['name'] for elem in list_drivers ] + initial.update({'nodeid': self.kwargs['baremetal'], 'node': node, 'drivers': drivers}) + else: + raise ValueError('Failover node not set') + else: + raise ValueError('Registered node not found') + except ValueError as e: + redirect = reverse('horizon:admin:rackhd:index') + messages.error(self.request, _(e.message)) + raise Exception(e.message) + self._remove_node(current_id) + messages.success(self.request, _('Removed node {0}'.format(current_id))) + return initial + + +class BareMetalDetailView(tables.DataTableView): + table_class = baremetal_tables.BareMetalDetailsTable + template_name = 'admin/rackhd/detail.html' + page_title = _('Details') + + class CatalogData: + def __init__(self, id, catalog): + self.id = id + self.catalog = catalog + + def get_data(self): + uuid = self.kwargs['baremetal'] + dmi = shovel.get_catalog_data_by_source(id = uuid, source = 'dmi') + scsi = shovel.get_catalog_data_by_source(id = uuid, source = 'lsscsi') + del dmi['Processor Information'] # don't feel like rendering this now + dmi.update({'Storage Information' : scsi}) + + catalog = json.dumps(dmi, sort_keys=True, indent=4, separators=(',', ': ')) + data = [ self.CatalogData(id, j2h.json2html.convert( json = catalog, table_attributes="class=\"table-bordered table\"" )) ] + return data + + +class BareMetalEventView(tables.MultiTableView): + table_classes = (baremetal_tables.BareMetalLastEventTable, + baremetal_tables.BareMetalAllEventsTable,) + template_name = 'admin/rackhd/events.html' + page_title = _('Events') + name = _("Events") + slug = "events" + + class HTMLData: + def __init__(self, id, html): + self.id = id + self.html = html + + class SELEventData: + def __init__(self, id, type, value, logid, number, time, date, event): + self.id = id + self.date = date + self.event = event + self.logid = logid + self.sensor_num = number + self.sensor_type = type + self.time = time + self.value = value + + def _find_ironic_node(self, id): + nodes = shovel.get_ironic_nodes() + for n in nodes['nodes']: + if n['extra'].get('nodeid', None) == id: + return n + + def get_lastevent_data(self): + id = self.kwargs['baremetal'] + try: + node = self._find_ironic_node(id) + if node is not None: + entry = node['extra']['events'] + return [ self.SELEventData(id, + entry['sensorType'], + entry['value'], + str(int(entry['logId'], 16)), + entry['sensorNumber'], + entry['time'], + entry['date'], + entry['event']) ] + except: + pass + return [] + + def get_allevents_data(self): + id = self.kwargs['baremetal'] + try: + sel = shovel.get_current_sel_data(id)[0].get('sel', []) + except KeyError as e: + redirect = reverse('horizon:admin:rackhd:index') + messages.error(self.request, _('No SEL data available, check node {0} poller task'.format(id))) + raise KeyError(e.message) + data = [] + if False: # TODO: enable this, view is not iterating the data list correctly + for entry in sel: + data.append(self.SELEventData(id, + entry['sensorType'], + entry['value'], + str(int(entry['logId'], 16)), + entry['sensorNumber'], + entry['time'], + entry['date'], + entry['event'] )) + else: # build html + rsel = list(reversed(sel)) + j = json.dumps({"":rsel}, sort_keys=True, indent=4, separators=(',', ': ')) + return [ self.HTMLData(id, j2h.json2html.convert(json=j, table_attributes="class=\"table\"")) ] + return data \ No newline at end of file diff --git a/setup_openstack.md b/setup_openstack.md new file mode 100644 index 0000000..3d2460b --- /dev/null +++ b/setup_openstack.md @@ -0,0 +1,115 @@ +# Configure Openstack to Boot Baremetal nodes Using Devstack + +## Download and install OpenStack using DevStack +- git clone https://github.com/openstack-dev/devstack.git devstack +- sudo ./devstack/tools/create-stack-user.sh +- sudo su stack +- cd ~ +- git clone https://github.com/openstack-dev/devstack.git devstack +- cd Devstack +- in Devstack, Create local.conf : + ```python + + [[local|localrc]] + #Enable Ironic API and Ironic Conductor + enable_service ironic + enable_service ir-api + enable_service ir-cond + #Enable Neutron which is required by Ironic and disable nova-network. + disable_service n-net + disable_service n-novnc + enable_service q-dhcp + enable_service q-svc + enable_service q-agt + enable_service q-l3 + enable_service q-meta + enable_service neutron + #Optional, to enable tempest configuration as part of devstack + disable_service tempest + disable_service heat h-api h-api-cfn h-api-cw h-eng + disable_service cinder c-sch c-api c-vol + ADMIN_PASSWORD=root + DATABASE_PASSWORD=$ADMIN_PASSWORD + RABBIT_PASSWORD=$ADMIN_PASSWORD + SERVICE_PASSWORD=$ADMIN_PASSWORD + SERVICE_TOKEN=$ADMIN_PASSWORD + HOST_IP=172.31.128.7 + #Create 3 virtual machines to pose as Ironic's baremetal nodes. + IRONIC_VM_COUNT=3 + IRONIC_VM_SSH_PORT=22 + IRONIC_BAREMETAL_BASIC_OPS=True + #The parameters below represent the minimum possible values to create + #functional nodes. + IRONIC_VM_SPECS_RAM=1024 + IRONIC_VM_SPECS_DISK=10 + #Size of the ephemeral partition in GB. Use 0 for no ephemeral partition. + IRONIC_VM_EPHEMERAL_DISK=0 + VIRT_DRIVER=ironic + #By default, DevStack creates a 10.0.0.0/24 network for instances. + #If this overlaps with the hosts network, you may adjust with the + #following. + NETWORK_GATEWAY=10.1.0.1 + FIXED_RANGE=10.1.0.0/24 + FIXED_NETWORK_SIZE=256 + #Neutron OVS (flat) + Q_PLUGIN=ml2 + Q_AGENT_EXTRA_OVS_OPTS=(network_vlan_ranges=physnet1) + OVS_VLAN_RANGE=physnet1 + PHYSICAL_NETWORK=physnet1 + OVS_PHYSICAL_BRIDGE=br-eth2 + #Log all output to files + LOGFILE=$HOME/devstack.log + SCREEN_LOGDIR=$HOME/logs + IRONIC_VM_LOG_DIR=$HOME/ironic-bm-logs + ``` +- Configure network Interface (assuming port eth2 is used to connect openstack to rackHD) + +![alt text](https://github.com/keedya/Shovel-horizon/blob/master/Shovel/snapshot/dev_config.PNG) + +- cat>>/etc/network/interfaces + ```python + + auto eth2 + iface eth2 inet static + address 172.31.128.7 + netmask 255.255.255.0 + ``` +- Restart network service + - sudo ifdown eth2 + - sudo ifup eth2 +- Run ./stack.sh + +## Configure Neutron + +Once the installation is completed, an external bridge can be setup for Neutron physical network + +- Bind eth2 to the external bridge: + - ovs-vsctl add-port br-eth2 eth2 +- Enable external network access under nested Open vSwitch + - ifconfig br-eth2 promisc up +- Update external bridge configuration cat>>/etc/network/interfaces + ```python + + auto eth2 + iface eth2 inet manual + auto br-eth2 + iface br-eth2 inet static + address 172.31.128.7 + netmask 255.255.255.0 + ``` +- Restart network service + - sudo ifdown br-eth2 + - sudo ifup br-eth2 + +- Create Flat netwok: + - Source ~/devstack/openrc admin admin + - neutron net-create flat-provider-network --shared --provider:network_type flat -- provider:physical_network physnet1 + - neutron subnet-create --name flat-provider-subnet --gateway 172.31.128.7 --dns-nameserver 172.31.128.254 --allocation-pool start=172.31.128.100,end=172.31.128.150 flat-provider-network 172.31.128.0/24 + +## Spawn an instance using nova service +- Login the horizon interface (user:admin,password:root) +- Use horizon to create new instances + + + +