diff --git a/.gitignore b/.gitignore index 8db2ef0..63d4cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ *.py[co] .*.swp *~ +build +dist +*egg-info +*egg diff --git a/README.md b/README.md new file mode 100644 index 0000000..4746c11 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +Virtual BMC +=========== + +A virtual BMC for controlling virtual machines using IPMI commands. + +Installation +------------ + +```bash +pip install virtualbmc +``` + +Usage +----- + +![alt text](images/demo.gif "Virtual BMC demo") + +Other supported commands +------------------------ + +```bash +# Power the virtual machine on or off +ipmitool -I lanplus -U admin -P password -H 127.0.0.1 power on|off + +# Check the power status +ipmitool -I lanplus -U admin -P password -H 127.0.0.1 power status + +# Set the boot device to network, hd or cdrom +ipmitool -I lanplus -U admin -P password -H 127.0.0.1 chassis bootdev pxe|disk|cdrom + +# Get the current boot device +ipmitool -I lanplus -U admin -P password -H 127.0.0.1 chassis bootparam get 5 +``` diff --git a/README.rst b/README.rst deleted file mode 100644 index 7d80a2a..0000000 --- a/README.rst +++ /dev/null @@ -1,27 +0,0 @@ -Virtual BMC -=========== - -A virtual BMC for controlling virtual machines with IPMI commands - -Usage ------ - -#. Create a virtual machine - -#. Run the virtual BMC:: - - python ./virtualbmc.py --domain-name - -#. Control it via IPMI:: - - # Power the virtual machine on or off - ipmitool -I lanplus -U admin -P password -H 127.0.0.1 power on|off - - # Check the power status - ipmitool -I lanplus -U admin -P password -H 127.0.0.1 power status - - # Set the boot device to network, hd or cdrom - ipmitool -I lanplus -U admin -P password -H 127.0.0.1 chassis bootdev pxe|disk|cdrom - - # Get the current boot device - ipmitool -I lanplus -U admin -P password -H 127.0.0.1 chassis bootparam get 5 diff --git a/images/demo.gif b/images/demo.gif new file mode 100644 index 0000000..bbafca6 Binary files /dev/null and b/images/demo.gif differ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..fe1246f --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +# 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 setuptools import setup + +setup(name='virtualbmc', + version='0.0.1', + description=('Create virtual BMCs for controlling virtual instances ' + 'via IPMI'), + url='http://github.com/umago/virtualbmc', + author='Lucas Alvares Gomes', + author_email='lucasagomes@gmail.com', + license='Apache License 2.0', + packages=['virtualbmc', 'virtualbmc.cmd'], + install_requires=['six', + 'python-daemon', + 'prettytable', + 'libvirt-python', + 'pyghmi>=0.9.8'], + entry_points = { + 'console_scripts': [ + 'vbmc = virtualbmc.cmd.vbmc:main', + ], + }, + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + ], +) diff --git a/virtualbmc.py b/virtualbmc.py deleted file mode 100644 index 32f012d..0000000 --- a/virtualbmc.py +++ /dev/null @@ -1,163 +0,0 @@ -# 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 argparse -import signal -import sys -import xml.etree.ElementTree as ET - -import libvirt -import pyghmi.ipmi.bmc as bmc - -# Power states -POWEROFF = 0 -POWERON = 1 - -# Boot device maps -GET_BOOT_DEVICES_MAP = { - 'network': 4, - 'hd': 8, - 'cdrom': 0x14, -} - -SET_BOOT_DEVICES_MAP = { - 'network': 'network', - 'hd': 'hd', - 'optical': 'cdrom', -} - - -class VirtualBMC(bmc.Bmc): - def __init__(self, username, password, port, address, - domain_name, libvirt_uri): - super(VirtualBMC, self).__init__({username: password}, - port=port, address=address) - self.domain_name = domain_name - - # Create the connection - self.conn = libvirt.open(libvirt_uri) - if self.conn is None: - print('Failed to open connection to the hypervisor ' - 'using the libvirt URI: "%s"' % libvirt_uri) - sys.exit(1) - - try: - self.domain = self.conn.lookupByName(self.domain_name) - except libvirt.libvirtError: - print('Failed to find the domain "%s"' % self.domain_name) - sys.exit(1) - - def get_boot_device(self): - parsed = ET.fromstring(self.domain.XMLDesc()) - boot_devs = parsed.findall('.//os/boot') - boot_dev = boot_devs[0].attrib['dev'] - return GET_BOOT_DEVICES_MAP.get(boot_dev, 0) - - def set_boot_device(self, bootdevice): - device = SET_BOOT_DEVICES_MAP.get(bootdevice) - if device is None: - print('Uknown boot device: %s' % bootdevice) - return 0xd5 - - parsed = ET.fromstring(self.domain.XMLDesc()) - os = parsed.find('os') - boot_list = os.findall('boot') - - # Clear boot list - for boot_el in boot_list: - os.remove(boot_el) - - boot_el = ET.SubElement(os, 'boot') - boot_el.set('dev', device) - - try: - self.conn.defineXML(ET.tostring(parsed)) - except libvirt.libvirtError as e: - print('Failed setting the boot device to "%s" on the ' - '"%s" domain' % (device, self.domain_name)) - - def get_power_state(self): - try: - if self.domain.isActive(): - return POWERON - except libvirt.libvirtError as e: - print('Error getting the power state of domain "%s". ' - 'Error: %s' % (self.domain_name, e)) - return - - return POWEROFF - - def power_off(self): - try: - self.domain.destroy() - except libvirt.libvirtError as e: - print('Error powering off the domain "%s". Error: %s' % - (self.domain_name, e)) - return - - def power_on(self): - try: - self.domain.create() - except libvirt.libvirtError as e: - print('Error powering on the domain "%s". Error: %s' % - (self.domain_name, e)) - return - - -def signal_handler(signal, frame): - print('SIGINT received, stopping the Virtual BMC...') - sys.exit(0) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser( - prog='Virtual BMC', - description='Virtual BMC for controlling virtual instances', - ) - parser.add_argument('--username', - dest='username', - default='admin', - help='The BMC username; defaults to "admin"') - parser.add_argument('--password', - dest='password', - default='password', - help='The BMC password; defaults to "password"') - parser.add_argument('--port', - dest='port', - type=int, - default=623, - help='Port to listen on; defaults to 623') - parser.add_argument('--address', - dest='address', - default='::', - help='Address to bind to; defaults to ::') - parser.add_argument('--domain-name', - dest='domain_name', - required=True, - help='The name of the virtual machine') - parser.add_argument('--libvirt-uri', - dest='libvirt_uri', - default="qemu:///system", - help='The libvirt URI; defaults to "qemu:///system"') - args = parser.parse_args() - vbmc = VirtualBMC(username=args.username, - password=args.password, - port=args.port, - address=args.address, - domain_name=args.domain_name, - libvirt_uri=args.libvirt_uri) - - # Register the signal handler - signal.signal(signal.SIGINT, signal_handler) - - # Start the virtual BMC - vbmc.listen() diff --git a/virtualbmc/__init__.py b/virtualbmc/__init__.py new file mode 100644 index 0000000..aace9a6 --- /dev/null +++ b/virtualbmc/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2016 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. + +__all__ = ['exception', 'VirtualBMC', 'VirtualBMCManager'] + +import exception +from virtualbmc import VirtualBMC +from virtualbmc import VirtualBMCManager diff --git a/virtualbmc/cmd/__init__.py b/virtualbmc/cmd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/virtualbmc/cmd/vbmc.py b/virtualbmc/cmd/vbmc.py new file mode 100644 index 0000000..0f37251 --- /dev/null +++ b/virtualbmc/cmd/vbmc.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 __future__ import print_function + +import argparse +import sys + +from prettytable import PrettyTable + +from virtualbmc import exception +from virtualbmc import VirtualBMCManager + + +def main(): + parser = argparse.ArgumentParser( + prog='Virtual BMC', + description='A virtual BMC for controlling virtual instances', + ) + subparsers = parser.add_subparsers() + + # create the parser for the "add" command + parser_add = subparsers.add_parser('add', help='Add a new virtual BMC') + parser_add.set_defaults(command='add') + parser_add.add_argument('domain_name', + help='The name of the virtual machine') + parser_add.add_argument('--username', + dest='username', + default='admin', + help='The BMC username; defaults to "admin"') + parser_add.add_argument('--password', + dest='password', + default='password', + help='The BMC password; defaults to "password"') + parser_add.add_argument('--port', + dest='port', + type=int, + default=623, + help='Port to listen on; defaults to 623') + parser_add.add_argument('--address', + dest='address', + default='::', + help='Address to bind to; defaults to ::') + parser_add.add_argument('--libvirt-uri', + dest='libvirt_uri', + default="qemu:///system", + help=('The libvirt URI; defaults to ' + '"qemu:///system"')) + + # create the parser for the "delete" command + parser_delete = subparsers.add_parser('delete', + help='Delete a virtual BMC') + parser_delete.set_defaults(command='delete') + parser_delete.add_argument('domain_name', + help='The name of the virtual machine') + + # create the parser for the "start" command + parser_start = subparsers.add_parser('start', help='Start a virtual BMC') + parser_start.set_defaults(command='start') + parser_start.add_argument('domain_name', + help='The name of the virtual machine') + + # create the parser for the "stop" command + parser_stop = subparsers.add_parser('stop', help='Stop a virtual BMC') + parser_stop.set_defaults(command='stop') + parser_stop.add_argument('domain_name', + help='The name of the virtual machine') + + # create the parser for the "list" command + parser_stop = subparsers.add_parser('list', help='list all virtual BMCs') + parser_stop.set_defaults(command='list') + + # create the parser for the "show" command + parser_stop = subparsers.add_parser('show', help='Show a virtual BMCs') + parser_stop.set_defaults(command='show') + parser_stop.add_argument('domain_name', + help='The name of the virtual machine') + + args = parser.parse_args() + manager = VirtualBMCManager() + + try: + if args.command == 'add': + manager.add(username=args.username, password=args.password, + port=args.port, address=args.address, + domain_name=args.domain_name, + libvirt_uri=args.libvirt_uri) + + elif args.command == 'delete': + manager.delete(args.domain_name) + + elif args.command == 'start': + manager.start(args.domain_name) + + elif args.command == 'stop': + manager.stop(args.domain_name) + + elif args.command == 'list': + ptable = PrettyTable(['Domain name', 'Status', 'Address', 'Port']) + for bmc in manager.list(): + ptable.add_row([bmc['domain_name'], bmc['status'], + bmc['address'], bmc['port']]) + print(ptable) + + elif args.command == 'show': + bmc = manager.show(args.domain_name) + for key, val in bmc.items(): + ptable.add_row([key, val]) + print(ptable) + + except (exception.LibvirtConnectionOpenError, + exception.DomainNotFound) as e: + print(e, file=sys.stderr) + exit(1) + + +if __name__ == '__main__': + main() diff --git a/virtualbmc/exception.py b/virtualbmc/exception.py new file mode 100644 index 0000000..dd2791f --- /dev/null +++ b/virtualbmc/exception.py @@ -0,0 +1,31 @@ +# 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 VirtualBMCError(Exception): + + message = None + + def __init__(self, message=None, **kwargs): + if self.message and kwargs: + self.message = self.message % kwargs + + super(VirtualBMCError, self).__init__(self.message) + + +class DomainNotFound(VirtualBMCError): + message = 'No domain with matching name %(domain)s was found' + + +class LibvirtConnectionOpenError(VirtualBMCError): + message = ('Fail to establish a connection with libvirt URI "%(uri)s". ' + 'Error: %(error)s') diff --git a/virtualbmc/virtualbmc.py b/virtualbmc/virtualbmc.py new file mode 100644 index 0000000..bc26fd7 --- /dev/null +++ b/virtualbmc/virtualbmc.py @@ -0,0 +1,300 @@ +# 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 __future__ import print_function + +import errno +import os +import signal +import shutil +import sys +import xml.etree.ElementTree as ET + +import daemon +import libvirt +import pyghmi.ipmi.bmc as bmc +from six.moves import configparser + +import exception + + +# Power states +POWEROFF = 0 +POWERON = 1 + +# BMC status +RUNNING = 'running' +DOWN = 'down' + +# Boot device maps +GET_BOOT_DEVICES_MAP = { + 'network': 4, + 'hd': 8, + 'cdrom': 0x14, +} + +SET_BOOT_DEVICES_MAP = { + 'network': 'network', + 'hd': 'hd', + 'optical': 'cdrom', +} + + +DEFAULT_SECTION = 'VirtualBMC' +CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.vbmc') + + +class libvirt_open(object): + + def __init__(self, uri, readonly=False): + self.uri = uri + self.readonly = readonly + + def __enter__(self): + try: + if self.readonly: + self.conn = libvirt.openReadOnly(self.uri) + else: + self.conn = libvirt.open(self.uri) + + return self.conn + + except libvirt.libvirtError as e: + raise exception.LibvirtConnectionOpenError(uri=self.uri, error=e) + + def __exit__(self, type, value, traceback): + self.conn.close() + + +def get_libvirt_domain(conn, domain): + try: + return conn.lookupByName(domain) + except libvirt.libvirtError: + raise exception.DomainNotFound(domain=domain) + + +def check_libvirt_connection_and_domain(uri, domain): + with libvirt_open(uri, readonly=True) as conn: + get_libvirt_domain(conn, domain) + + +def is_pid_running(pid): + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +class VirtualBMC(bmc.Bmc): + + def __init__(self, username, password, port, address, + domain_name, libvirt_uri): + super(VirtualBMC, self).__init__({username: password}, + port=port, address=address) + self.libvirt_uri = libvirt_uri + self.domain_name = domain_name + + def get_boot_device(self): + with libvirt_open(self.libvirt_uri, readonly=True) as conn: + domain = get_libvirt_domain(conn, self.domain_name) + parsed = ET.fromstring(domain.XMLDesc()) + boot_devs = parsed.findall('.//os/boot') + boot_dev = boot_devs[0].attrib['dev'] + return GET_BOOT_DEVICES_MAP.get(boot_dev, 0) + + def set_boot_device(self, bootdevice): + device = SET_BOOT_DEVICES_MAP.get(bootdevice) + if device is None: + print('Uknown boot device: %s' % bootdevice) + return 0xd5 + + with libvirt_open(self.libvirt_uri) as conn: + domain = get_libvirt_domain(conn, self.domain_name) + parsed = ET.fromstring(domain.XMLDesc()) + os = parsed.find('os') + boot_list = os.findall('boot') + + # Clear boot list + for boot_el in boot_list: + os.remove(boot_el) + + boot_el = ET.SubElement(os, 'boot') + boot_el.set('dev', device) + + try: + conn.defineXML(ET.tostring(parsed)) + except libvirt.libvirtError as e: + print('Failed setting the boot device to "%s" on the ' + '"%s" domain' % (device, self.domain_name), + file=sys.stderr) + + def get_power_state(self): + try: + with libvirt_open(self.libvirt_uri, readonly=True) as conn: + domain = get_libvirt_domain(conn, self.domain_name) + if domain.isActive(): + return POWERON + except libvirt.libvirtError as e: + print('Error getting the power state of domain "%s". ' + 'Error: %s' % (self.domain_name, e), file=sys.stderr) + return + + return POWEROFF + + def power_off(self): + try: + with libvirt_open(self.libvirt_uri) as conn: + domain = get_libvirt_domain(conn, self.domain_name) + domain.destroy() + except libvirt.libvirtError as e: + print('Error powering off the domain "%s". Error: %s' % + (self.domain_name, e), file=sys.stderr) + return + + def power_on(self): + try: + with libvirt_open(self.libvirt_uri) as conn: + domain = get_libvirt_domain(conn, self.domain_name) + domain.create() + except libvirt.libvirtError as e: + print('Error powering on the domain "%s". Error: %s' % + (self.domain_name, e)) + return + + +class VirtualBMCManager(object): + + def _parse_config(self, domain_name): + config_path = os.path.join(CONFIG_PATH, domain_name, 'config') + if not os.path.exists(config_path): + raise exception.DomainNotFound(domain=domain_name) + + config = configparser.ConfigParser() + config.read(config_path) + + bmc = {} + for item in ('username', 'password', 'address', + 'domain_name', 'libvirt_uri'): + bmc[item] = config.get(DEFAULT_SECTION, item) + + # Port needs to be int + bmc['port'] = int(config.get(DEFAULT_SECTION, 'port')) + + return bmc + + def _show(self, domain_name): + running = False + try: + pidfile_path = os.path.join(CONFIG_PATH, domain_name, 'pid') + with open(pidfile_path, 'r') as f: + pid = int(f.read()) + + running = is_pid_running(pid) + except IOError: + pass + + bmc_config = self._parse_config(domain_name) + bmc_config['status'] = RUNNING if running else DOWN + return bmc_config + + def add(self, username, password, port, address, + domain_name, libvirt_uri): + # Check libvirt and domain + check_libvirt_connection_and_domain(libvirt_uri, domain_name) + + domain_path = os.path.join(CONFIG_PATH, domain_name) + try: + os.makedirs(domain_path) + except OSError as e: + if e.errno == errno.EEXIST: + sys.exit('Domain %s already exist' % domain_name) + + config_path = os.path.join(domain_path, 'config') + with open(config_path, 'w') as f: + config = configparser.ConfigParser() + config.add_section(DEFAULT_SECTION) + config.set(DEFAULT_SECTION, 'username', username) + config.set(DEFAULT_SECTION, 'password', password) + config.set(DEFAULT_SECTION, 'port', port) + config.set(DEFAULT_SECTION, 'address', address) + config.set(DEFAULT_SECTION, 'domain_name', domain_name) + config.set(DEFAULT_SECTION, 'libvirt_uri', libvirt_uri) + config.write(f) + + def delete(self, domain_name): + domain_path = os.path.join(CONFIG_PATH, domain_name) + if not os.path.exists(domain_path): + raise exception.DomainNotFound(domain=domain_name) + + self.stop(domain_name) + shutil.rmtree(domain_path) + + def start(self, domain_name): + domain_path = os.path.join(CONFIG_PATH, domain_name) + if not os.path.exists(domain_path): + raise exception.DomainNotFound(domain=domain_name) + + bmc_config = self._parse_config(domain_name) + + # Check libvirt and domain + check_libvirt_connection_and_domain( + bmc_config['libvirt_uri'], domain_name) + + pidfile_path = os.path.join(domain_path, 'pid') + + with daemon.DaemonContext(stderr=sys.stderr): + # FIXME(lucasagomes): pyghmi start the sockets when the + # class is instantiated, therefore we need to create the object + # within the daemon context + vbmc = VirtualBMC(**bmc_config) + + # Save the PID number + with open(pidfile_path, 'w') as f: + f.write(str(os.getpid())) + + vbmc.listen() + + def stop(sel, domain_name): + domain_path = os.path.join(CONFIG_PATH, domain_name) + if not os.path.exists(domain_path): + raise exception.DomainNotFound(domain=domain_name) + + pidfile_path = os.path.join(domain_path, 'pid') + pid = None + try: + with open(pidfile_path, 'r') as f: + pid = int(f.read()) + except IOError: + # LOG + return + else: + os.remove(pidfile_path) + + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + def list(self): + bmcs = [] + try: + for domain in os.listdir(CONFIG_PATH): + bmcs.append(self._show(domain)) + except OSError as e: + if e.errno == errno.EEXIST: + return bmcs + + return bmcs + + def show(self, domain_name): + return self._show(domain_name)