Abstract away the virtualization management interface

This change decouples the virtualization drivers (presently
just libvirt) from the Redfish REST API server. The
goal is to be able to support multiple virtualization
backends in parallel by way of running dedicated and
unified drivers. Most immediate candidate would be
novaclient (e.g. OpenStack API).

The change has two parts to it:

* introduced an abstract base class for the virtualization
  driver
* introduced the libvirt driver based on the existing
  libvirt code refactored into to the abstract driver
  framework
* implemented Redfish-compliant error reporting

There should be no change in functionality of the libvirt
driver.

Change-Id: Ic4df2fc2986e7077afb1f937754f0f20b4f8ed3a
This commit is contained in:
Ilya Etingof 2017-11-27 18:50:33 +01:00
parent c3b8fd8ec3
commit a586c55a96
8 changed files with 442 additions and 113 deletions

View File

View File

@ -0,0 +1,102 @@
# Copyright 2018 Red Hat, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import abc
import six
@six.add_metaclass(abc.ABCMeta)
class AbstractDriver(object):
"""Base class for all virtualization drivers"""
@abc.abstractproperty
def driver(self):
"""Return human-friendly driver information
:returns: driver information as `str`
"""
@abc.abstractproperty
def systems(self):
"""Return available computer systems
:returns: list of computer systems names.
"""
@abc.abstractmethod
def uuid(self, identity):
"""Get computer system UUID
The universal unique identifier (UUID) for this system. Can be used
in place of system name if there are duplicates.
If virtualization backend does not support non-unique system identity,
this property may just return the `identity`.
:returns: computer system UUID
"""
@abc.abstractmethod
def get_power_state(self, identity):
"""Get computer system power state
:returns: current power state as *On* or *Off* `str` or `None`
if power state can't be determined
"""
@abc.abstractmethod
def set_power_state(self, identity, state):
"""Set computer system power state
:param state: string literal requesting power state transition.
If not specified, current system power state is returned.
Valid values are: *On*, *ForceOn*, *ForceOff*, *GracefulShutdown*,
*GracefulRestart*, *ForceRestart*, *Nmi*.
:raises: `FishyError` if power state can't be set
"""
@abc.abstractmethod
def get_boot_device(self, identity):
"""Get computer system boot device name
:returns: boot device name as `str` or `None` if device name
can't be determined
"""
@abc.abstractmethod
def set_boot_device(self, identity, boot_source):
"""Set computer system boot device name
:param boot_source: string literal requesting boot device change on the
system. If not specified, current boot device is returned.
Valid values are: *Pxe*, *Hdd*, *Cd*.
:raises: `FishyError` if boot device can't be set
"""
@abc.abstractmethod
def get_total_memory(self, identity):
"""Get computer system total memory
:returns: available RAM in GiB as `int` or `None` if total memory
count can't be determined
"""
@abc.abstractmethod
def get_total_cpus(self, identity):
"""Get computer system total count of available CPUs
:returns: available CPU count as `int` or `None` if CPU count
can't be determined
"""

View File

@ -0,0 +1,235 @@
# Copyright 2018 Red Hat, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import xml.etree.ElementTree as ET
from sushy_tools.emulator.drivers.base import AbstractDriver
from sushy_tools.error import FishyError
import libvirt
class libvirt_open(object):
def __init__(self, uri, readonly=False):
self._uri = uri
self._readonly = readonly
def __enter__(self):
try:
self._conn = (libvirt.openReadOnly(self._uri)
if self._readonly else
libvirt.open(self._uri))
return self._conn
except libvirt.libvirtError as e:
msg = ('Error when connecting to the libvirt URI "%(uri)s": '
'%(error)s' % {'uri': self._uri, 'error': e})
raise FishyError(msg)
def __exit__(self, type, value, traceback):
self._conn.close()
class LibvirtDriver(AbstractDriver):
"""Libvirt driver"""
BOOT_DEVICE_MAP = {
'Pxe': 'network',
'Hdd': 'hd',
'Cd': 'cdrom',
}
BOOT_DEVICE_MAP_REV = {v: k for k, v in BOOT_DEVICE_MAP.items()}
LIBVIRT_URI = 'qemu:///system'
def __init__(self, uri=None):
self._uri = uri or self.LIBVIRT_URI
@property
def driver(self):
"""Return human-friendly driver information
:returns: driver information as string
"""
return '<libvirt driver>'
@property
def systems(self):
"""Return available computer systems
:returns: list of computer systems names.
"""
with libvirt_open(self._uri, readonly=True) as conn:
return conn.listDefinedDomains()
def uuid(self, identity):
"""Get computer system UUID
:returns: computer system UUID
"""
with libvirt_open(self._uri, readonly=True) as conn:
domain = conn.lookupByName(identity)
return domain.UUIDString()
def get_power_state(self, identity):
"""Get computer system power state
:returns: current power state as *On* or *Off* `str` or `None`
if power state can't be determined
"""
with libvirt_open(self._uri, readonly=True) as conn:
domain = conn.lookupByName(identity)
return 'On' if domain.isActive() else 'Off'
def set_power_state(self, identity, state):
"""Set computer system power state
:param state: string literal requesting power state transition.
If not specified, current system power state is returned.
Valid values are: *On*, *ForceOn*, *ForceOff*, *GracefulShutdown*,
*GracefulRestart*, *ForceRestart*, *Nmi*.
:raises: `FishyError` if power state can't be set
"""
with libvirt_open(self._uri) as conn:
domain = conn.lookupByName(identity)
try:
if state in ('On', 'ForceOn'):
if not domain.isActive():
domain.create()
elif state == 'ForceOff':
if domain.isActive():
domain.destroy()
elif state == 'GracefulShutdown':
if domain.isActive():
domain.shutdown()
elif state == 'GracefulRestart':
if domain.isActive():
domain.reboot()
elif state == 'ForceRestart':
if domain.isActive():
domain.reset()
elif state == 'Nmi':
if domain.isActive():
domain.injectNMI()
except libvirt.libvirtError as e:
msg = ('Error changing power state at libvirt URI "%(uri)s": '
'%(error)s' % {'uri': self._uri, 'error': e})
raise FishyError(msg)
def get_boot_device(self, identity):
"""Get computer system boot device name
:returns: boot device name as `str` or `None` if device name
can't be determined
"""
with libvirt_open(self._uri, readonly=True) as conn:
domain = conn.lookupByName(identity)
tree = ET.fromstring(domain.XMLDesc())
boot_element = tree.find('.//boot')
if boot_element is not None:
boot_source_target = (
self.BOOT_DEVICE_MAP_REV.get(boot_element.get('dev'))
)
return boot_source_target
def set_boot_device(self, identity, boot_source):
"""Get/Set computer system boot device name
:param boot_source: optional string literal requesting boot device
change on the system. If not specified, current boot device is
returned. Valid values are: *Pxe*, *Hdd*, *Cd*.
:raises: `FishyError` if boot device can't be set
"""
with libvirt_open(self._uri) as conn:
domain = conn.lookupByName(identity)
tree = ET.fromstring(domain.XMLDesc())
try:
target = self.BOOT_DEVICE_MAP[boot_source]
except KeyError:
msg = ('Unknown power state requested: '
'%(boot_source)s' % {'boot_source': boot_source})
raise FishyError(msg)
for os_element in tree.findall('os'):
# Remove all "boot" elements
for boot_element in os_element.findall('boot'):
os_element.remove(boot_element)
# Add a new boot element with the request boot device
boot_element = ET.SubElement(os_element, 'boot')
boot_element.set('dev', target)
try:
self._conn.defineXML(ET.tostring(tree).decode('utf-8'))
except libvirt.libvirtError as e:
msg = ('Error changing boot device at libvirt URI "%(uri)s": '
'%(error)s' % {'uri': self._uri, 'error': e})
raise FishyError(msg)
def get_total_memory(self, identity):
"""Get computer system total memory
:returns: available RAM in GiB as `int` or `None` if total memory
count can't be determined
"""
with libvirt_open(self._uri, readonly=True) as conn:
domain = conn.lookupByName(identity)
return int(domain.maxMemory() / 1024 / 1024)
def get_total_cpus(self, identity):
"""Get computer system total count of available CPUs
:returns: available CPU count as `int` or `None` if CPU count
can't be determined
"""
with libvirt_open(self._uri, readonly=True) as conn:
domain = conn.lookupByName(identity)
tree = ET.fromstring(domain.XMLDesc())
total_cpus = 0
if domain.isActive():
total_cpus = domain.maxVcpus()
# If we can't get it from maxVcpus() try to find it by
# inspecting the domain XML
if total_cpus <= 0:
vcpu_element = tree.find('.//vcpu')
if vcpu_element is not None:
total_cpus = int(vcpu_element.text)
return total_cpus or None

View File

@ -16,115 +16,94 @@
# under the License.
import argparse
import functools
import os
import ssl
import xml.etree.ElementTree as ET
import flask
import libvirt
from sushy_tools.emulator.drivers import libvirtdriver
app = flask.Flask(__name__)
# Turn off strict_slashes on all routes
app.url_map.strict_slashes = False
LIBVIRT_URI = None
BOOT_DEVICE_MAP = {
'Pxe': 'network',
'Hdd': 'hd',
'Cd': 'cdrom',
}
BOOT_DEVICE_MAP_REV = {v: k for k, v in BOOT_DEVICE_MAP.items()}
driver = None
class libvirt_open(object):
def init_virt_driver(decorated_func):
@functools.wraps(decorated_func)
def decorator(*args, **kwargs):
global driver
def __init__(self, uri, readonly=False):
self.uri = uri
self.readonly = readonly
if driver is None:
def __enter__(self):
try:
self._conn = (libvirt.openReadOnly(self.uri)
if self.readonly else
libvirt.open(self.uri))
return self._conn
except libvirt.libvirtError as e:
print('Error when connecting to the libvirt URI "%(uri)s": '
'%(error)s' % {'uri': self.uri, 'error': e})
flask.abort(500)
driver = libvirtdriver.LibvirtDriver(
os.environ.get('SUSHY_EMULATOR_LIBVIRT_URL')
)
def __exit__(self, type, value, traceback):
self._conn.close()
return decorated_func(*args, **kwargs)
return decorator
def get_libvirt_domain(connection, domain):
try:
return connection.lookupByName(domain)
except libvirt.libvirtError:
flask.abort(404)
def returns_json(decorated_func):
@functools.wraps(decorated_func)
def decorator(*args, **kwargs):
response = decorated_func(*args, **kwargs)
if isinstance(response, flask.Response):
return flask.Response(response, content_type='application/json')
else:
return response
return decorator
@app.errorhandler(Exception)
@returns_json
def all_exception_handler(message):
return flask.render_template('error.json', message=message)
@app.route('/redfish/v1/')
@init_virt_driver
@returns_json
def root_resource():
return flask.render_template('root.json')
@app.route('/redfish/v1/Systems')
@init_virt_driver
@returns_json
def system_collection_resource():
with libvirt_open(LIBVIRT_URI, readonly=True) as conn:
domains = conn.listDefinedDomains()
return flask.render_template(
'system_collection.json', system_count=len(domains),
systems=domains)
systems = driver.systems
def _get_total_cpus(domain, tree):
total_cpus = 0
if domain.isActive():
total_cpus = domain.maxVcpus()
else:
# If we can't get it from maxVcpus() try to find it by
# inspecting the domain XML
if total_cpus <= 0:
vcpu_element = tree.find('.//vcpu')
if vcpu_element is not None:
total_cpus = int(vcpu_element.text)
return total_cpus
def _get_boot_source_target(tree):
boot_source_target = None
boot_element = tree.find('.//boot')
if boot_element is not None:
boot_source_target = (
BOOT_DEVICE_MAP_REV.get(boot_element.get('dev')))
return boot_source_target
return flask.render_template(
'system_collection.json', system_count=len(systems),
systems=systems)
@app.route('/redfish/v1/Systems/<identity>', methods=['GET', 'PATCH'])
@init_virt_driver
@returns_json
def system_resource(identity):
if flask.request.method == 'GET':
with libvirt_open(LIBVIRT_URI, readonly=True) as conn:
domain = get_libvirt_domain(conn, identity)
power_state = 'On' if domain.isActive() else 'Off'
total_memory_gb = int(domain.maxMemory() / 1024 / 1024)
tree = ET.fromstring(domain.XMLDesc())
total_cpus = _get_total_cpus(domain, tree)
boot_source_target = _get_boot_source_target(tree)
return flask.render_template(
'system.json', identity=identity, uuid=domain.UUIDString(),
power_state=power_state, total_memory_gb=total_memory_gb,
total_cpus=total_cpus, boot_source_target=boot_source_target)
return flask.render_template(
'system.json', identity=identity,
uuid=driver.uuid(identity),
power_state=driver.get_power_state(identity),
total_memory_gb=driver.get_total_memory(identity),
total_cpus=driver.get_total_cpus(identity),
boot_source_target=driver.get_boot_device(identity)
)
elif flask.request.method == 'PATCH':
boot = flask.request.json.get('Boot')
if not boot:
return 'PATCH only works for the Boot element', 400
target = BOOT_DEVICE_MAP.get(boot.get('BootSourceOverrideTarget'))
target = boot.get('BootSourceOverrideTarget')
if not target:
return 'Missing the BootSourceOverrideTarget element', 400
@ -135,50 +114,19 @@ def system_resource(identity):
# TODO(lucasagomes): We should allow changing the boot mode from
# BIOS to UEFI (and vice-versa)
with libvirt_open(LIBVIRT_URI) as conn:
domain = get_libvirt_domain(conn, identity)
tree = ET.fromstring(domain.XMLDesc())
for os_element in tree.findall('os'):
# Remove all "boot" elements
for boot_element in os_element.findall('boot'):
os_element.remove(boot_element)
# Add a new boot element with the request boot device
boot_element = ET.SubElement(os_element, 'boot')
boot_element.set('dev', target)
conn.defineXML(ET.tostring(tree).decode('utf-8'))
driver.set_boot_device(identity, target)
return '', 204
@app.route('/redfish/v1/Systems/<identity>/Actions/ComputerSystem.Reset',
methods=['POST'])
@init_virt_driver
@returns_json
def system_reset_action(identity):
reset_type = flask.request.json.get('ResetType')
with libvirt_open(LIBVIRT_URI) as conn:
domain = get_libvirt_domain(conn, identity)
try:
if reset_type in ('On', 'ForceOn'):
if not domain.isActive():
domain.create()
elif reset_type == 'ForceOff':
if domain.isActive():
domain.destroy()
elif reset_type == 'GracefulShutdown':
if domain.isActive():
domain.shutdown()
elif reset_type == 'GracefulRestart':
if domain.isActive():
domain.reboot()
elif reset_type == 'ForceRestart':
if domain.isActive():
domain.reset()
elif reset_type == 'Nmi':
if domain.isActive():
domain.injectNMI()
except libvirt.libvirtError:
flask.abort(500)
driver.set_power_state(identity, reset_type)
return '', 204
@ -191,8 +139,10 @@ def parse_args():
help='The port to bind the server to')
parser.add_argument('-u', '--libvirt-uri',
type=str,
default='qemu:///system',
help='The libvirt URI')
default='',
help='The libvirt URI. Can also be set via '
'environment variable '
'$SUSHY_EMULATOR_LIBVIRT_URL')
parser.add_argument('-c', '--ssl-certificate',
type=str,
help='SSL certificate to use for HTTPS')
@ -203,9 +153,11 @@ def parse_args():
def main():
global LIBVIRT_URI
global driver
args = parse_args()
LIBVIRT_URI = args.libvirt_uri
driver = libvirtdriver.LibvirtDriver(args.libvirt_uri)
ssl_context = None
if args.ssl_certificate and args.ssl_key:

View File

@ -0,0 +1,12 @@
{
"error": {
"code": "Base.1.0.GeneralError",
"message": "{{ message }}",
"@Message.ExtendedInfo": [
{
"@odata.type": "/redfish/v1/$metadata#Message.1.0.0.Message",
"MessageId": "Base.1.0.GeneralError"
}
]
}
}

View File

@ -18,15 +18,19 @@
"HealthRollUp": "OK"
},
"IndicatorLED": "Off",
{%- if power_state %}
"PowerState": "{{ power_state }}",
{%- endif %}
"Boot": {
"BootSourceOverrideEnabled": "Continuous",
{%- if boot_source_target %}
"BootSourceOverrideTarget": "{{ boot_source_target }}",
"BootSourceOverrideTarget@Redfish.AllowableValues": [
"Pxe",
"Cd",
"Hdd"
],
{%- endif %}
"BootSourceOverrideMode": "UEFI",
"UefiTargetBootSourceOverride": "/0x31/0x33/0x01/0x01"
},
@ -55,7 +59,9 @@
},
"BiosVersion": "P79 v1.33 (02/28/2015)",
"ProcessorSummary": {
{%- if total_cpus %}
"Count": {{ total_cpus }},
{%- endif %}
"ProcessorFamily": "Multi-Core Intel(R) Xeon(R) processor 7xxx Series",
"Status": {
"State": "Enabled",
@ -64,7 +70,9 @@
}
},
"MemorySummary": {
{%- if total_memory_gb %}
"TotalSystemMemoryGiB": {{ total_memory_gb }},
{%- endif %}
"Status": {
"State": "Enabled",
"Health": "OK",

18
sushy_tools/error.py Normal file
View File

@ -0,0 +1,18 @@
# Copyright 2018 Red Hat, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
class FishyError(Exception):
"""Create generic sushy-tools exception object"""

View File

@ -22,6 +22,7 @@ import sys
try:
from http import server as http_server
except ImportError:
import BaseHTTPServer as http_server # Py2
@ -91,6 +92,7 @@ def parse_args():
def main():
global REDFISH_MOCKUP_FILES
args = parse_args()
if not os.path.exists(args.mockup_files):
print('Mockup files %s not found' % args.mockup_files)