diff --git a/doc/source/conf.py b/doc/source/conf.py index a10deb0..ffd7727 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -22,7 +22,6 @@ sys.path.insert(0, os.path.abspath('../..')) # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', - #'sphinx.ext.intersphinx', 'oslosphinx' ] @@ -72,4 +71,4 @@ latex_documents = [ ] # Example configuration for intersphinx: refer to the Python standard library. -#intersphinx_mapping = {'http://docs.python.org/': None} +# intersphinx_mapping = {'http://docs.python.org/': None} diff --git a/etc/fuxi-config-generator.conf b/etc/fuxi-config-generator.conf new file mode 100644 index 0000000..df4b05d --- /dev/null +++ b/etc/fuxi-config-generator.conf @@ -0,0 +1,4 @@ +[DEFAULT] +output_file = etc/fuxi.conf.sample +wrap_width = 79 +namespace = fuxi diff --git a/etc/fuxi.conf b/etc/fuxi.conf new file mode 100644 index 0000000..1a3e27e --- /dev/null +++ b/etc/fuxi.conf @@ -0,0 +1,23 @@ +[DEFAULT] +fuxi_port = 7879 +my_ip = +volume_providers = cinder +volume_from = fuxi +default_volume_size = 1 +volume_dir = /fuxi/data +threaded = true +debug = False + +[keystone] +region = +auth_url = +admin_user = +admin_password = +admin_tenant_name = +admin_token = +auth_insecure = true + +[cinder] +volume_connector = osbrick +multiattach = false +fstype = ext4 diff --git a/etc/fuxi.json b/etc/fuxi.json new file mode 100644 index 0000000..b33156d --- /dev/null +++ b/etc/fuxi.json @@ -0,0 +1,4 @@ +{ + "Name": "fuxi", + "Addr": "http://127.0.0.1:7879" +} diff --git a/etc/rootwrap.conf b/etc/rootwrap.conf new file mode 100644 index 0000000..bbc711b --- /dev/null +++ b/etc/rootwrap.conf @@ -0,0 +1,27 @@ +# Configuration for fuxi-rootwrap +# This file should be owned by (and only-writeable by) the root user + +[DEFAULT] +# List of directories to load filter definitions from (separated by ','). +# These directories MUST all be only writeable by root ! +filters_path=/etc/fuxi/rootwrap.d + +# List of directories to search executables in, in case filters do not +# explicitely specify a full path (separated by ',') +# If not specified, defaults to system PATH environment variable. +# These directories MUST all be only writeable by root ! +exec_dirs=/sbin,/usr/sbin,/bin,/usr/bin,/usr/local/bin,/usr/local/sbin + +# Enable logging to syslog +# Default value is False +use_syslog=False + +# Which syslog facility to use. +# Valid values include auth, authpriv, syslog, local0, local1... +# Default value is 'syslog' +syslog_log_facility=syslog + +# Which messages to log. +# INFO means log all usage +# ERROR means only log unsuccessful attempts +syslog_log_level=ERROR diff --git a/etc/rootwrap.d/fuxi.filters b/etc/rootwrap.d/fuxi.filters new file mode 100644 index 0000000..2ed90a9 --- /dev/null +++ b/etc/rootwrap.d/fuxi.filters @@ -0,0 +1,31 @@ +# fuxi-rootwrap command filters +# This file should be owned by (and only-writeable by) the root user + +[Filters] +# os-brick library commands +# os_brick.privileged.run_as_root oslo.privsep context +# This line ties the superuser privs with the config files, context name, +# and (implicitly) the actual python code invoked. +privsep-rootwrap: RegExpFilter, privsep-helper, root, privsep-helper, --config-file, /etc/(?!\.\.).*, --privsep_context, os_brick.privileged.default, --privsep_sock_path, /tmp/.* +# The following and any cinder/brick/* entries should all be obsoleted +# by privsep, and may be removed once the os-brick version requirement +# is updated appropriately. +scsi_id: CommandFilter, /lib/udev/scsi_id, root +drbdadm: CommandFilter, drbdadm, root +iscsiadm: CommandFilter, iscsiadm, root +sg_scan: CommandFilter, sg_scan, root +systool: CommandFilter, systool, root +cat: CommandFilter, cat, root + +# fuxi/connector/cloudconnector/openstack.py +ln: CommandFilter, ln, root + +# fuxi/blockdevice.py +mount: CommandFilter, mount, root +umount: CommandFilter, umount, root +mkfs: CommandFilter, mkfs, root + +mkdir: CommandFilter, mkdir, root +tee: CommandFilter, tee, root +ls: CommandFilter, ls, root +rm: CommandFilter, rm, root diff --git a/fuxi/__init__.py b/fuxi/__init__.py index f4e7fe6..547f25a 100644 --- a/fuxi/__init__.py +++ b/fuxi/__init__.py @@ -12,8 +12,6 @@ # License for the specific language governing permissions and limitations # under the License. -import pbr.version +from fuxi import utils - -__version__ = pbr.version.VersionInfo( - 'fuxi').version_string() +app = utils.make_json_app(__name__) diff --git a/fuxi/common/__init__.py b/fuxi/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/common/blockdevice.py b/fuxi/common/blockdevice.py new file mode 100644 index 0000000..a38ea1a --- /dev/null +++ b/fuxi/common/blockdevice.py @@ -0,0 +1,36 @@ +# 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 glob + +from oslo_log import log as logging +from oslo_utils import units + +from fuxi import exceptions +from fuxi.i18n import _LE + +LOG = logging.getLogger(__name__) + + +class BlockerDeviceManager(object): + def device_scan(self): + return glob.glob('/sys/block/*') + + def get_device_size(self, device): + try: + nr_sectors = open(device + '/size').read().rstrip('\n') + sect_size = open(device + '/queue/hw_sector_size')\ + .read().rstrip('\n') + return (float(nr_sectors) * float(sect_size)) / units.Gi + except IOError as e: + LOG.error(_LE("Failed to read device size. {0}").format(e)) + raise exceptions.FuxiException(e.message) diff --git a/fuxi/common/config.py b/fuxi/common/config.py new file mode 100644 index 0000000..592964a --- /dev/null +++ b/fuxi/common/config.py @@ -0,0 +1,108 @@ +# 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 os + +from oslo_config import cfg +from oslo_log import log as logging + +from fuxi import i18n +from fuxi.version import version_info + +_ = i18n._ + +default_opts = [ + cfg.StrOpt('my_ip', + help=_('IP address of this machine.')), + cfg.IntOpt('fuxi_port', + default=7879, + help=_('Port for fuxi volume driver server.')), + cfg.StrOpt('volume_dir', + default='/fuxi/data', + help=_('At which the docker volume will create.')), + cfg.ListOpt('volume_providers', + help=_('Volume storage backends that provide volume for ' + 'Docker')), + cfg.StrOpt('volume_from', + default='fuxi', + help=_('Setting label for volume.')), + cfg.IntOpt('default_volume_size', + default=1, + help=_('Default size for volume.')), + cfg.BoolOpt('threaded', + default=True, + help=_('Make this volume plugin run in multi-thread.')), + cfg.StrOpt('rootwrap_config', + default='/etc/fuxi/rootwrap.conf'), +] + +keystone_opts = [ + cfg.StrOpt('region', + default=os.environ.get('REGION'), + help=_('The region that this machine belongs to.')), + cfg.StrOpt('auth_url', + default=os.environ.get('IDENTITY_URL'), + help=_('The URL for accessing the identity service.')), + cfg.StrOpt('admin_user', + default=os.environ.get('SERVICE_USER'), + help=_('The username to auth with the identity service.')), + cfg.StrOpt('admin_tenant_name', + default=os.environ.get('SERVICE_TENANT_NAME'), + help=_('The tenant name to auth with the identity service.')), + cfg.StrOpt('admin_password', + default=os.environ.get('SERVICE_PASSWORD'), + help=_('The password to auth with the identity service.')), + cfg.StrOpt('admin_token', + default=os.environ.get('SERVICE_TOKEN'), + help=_('The admin token.')), + cfg.StrOpt('auth_ca_cert', + default=os.environ.get('SERVICE_CA_CERT'), + help=_('The CA certification file.')), + cfg.BoolOpt('auth_insecure', + default=True, + help=_("Turn off verification of the certificate for ssl.")), +] + +cinder_opts = [ + cfg.StrOpt('volume_connector', + default='osbrick', + help=_('Volume connector for attach volume to this server, ' + 'or detach volume from this server.')), + cfg.StrOpt('availability_zone', + default=None, + help=_('AZ in which the current machine creates, ' + 'and volume is going to create.')), + cfg.StrOpt('volume_type', + default=None, + help=_('Volume type to create volume.')), + cfg.StrOpt('fstype', + default='ext4', + help=_('Default filesystem type for volume.')), + cfg.BoolOpt('multiattach', + default=False, + help=_('Allow the volume to be attached to more than ' + 'one instance.')) +] + + +CONF = cfg.CONF +CONF.register_opts(default_opts) +CONF.register_opts(keystone_opts, group='keystone') +CONF.register_opts(cinder_opts, group='cinder') + +# Setting oslo.log options for logging. +logging.register_options(CONF) + + +def init(args, **kwargs): + cfg.CONF(args=args, project='fuxi', + version=version_info.release_string(), **kwargs) diff --git a/fuxi/common/constants.py b/fuxi/common/constants.py new file mode 100644 index 0000000..5f2d2eb --- /dev/null +++ b/fuxi/common/constants.py @@ -0,0 +1,44 @@ +# 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. + +VOLUME_FROM = 'volume_from' +DOCKER_VOLUME_NAME = 'docker_volume_name' + +# Volume states +UNKNOWN = 'unknown' +NOT_ATTACH = 'not_attach' +ATTACH_TO_THIS = 'attach_to_this' +ATTACH_TO_OTHER = 'attach_to_other' + +# If volume_provider is cinder, and if cinder volume is attached to this server +# by Nova, an link file will create under this directory to match attached +# volume. Of course, creating link file will decrease interact time +# with backend providers in some cases. +VOLUME_LINK_DIR = '/dev/disk/by-id/' + +# Volume scanning interval +VOLUME_SCAN_TIME_DELAY = 0.3 + +# Timeout for destroying volume from backend provider +DESTROY_VOLUME_TIMEOUT = 300 + +# Timeout for monitoring volume status +MONITOR_STATE_TIMEOUT = 600 + +# Device scan interval +DEVICE_SCAN_TIME_DELAY = 0.3 + +# Timeout for scanning device +DEVICE_SCAN_TIMEOUT = 10 + +# Timeout for querying meta-data from localhost +CURL_MD_TIMEOUT = 10 diff --git a/fuxi/common/mount.py b/fuxi/common/mount.py new file mode 100644 index 0000000..a0e3e5e --- /dev/null +++ b/fuxi/common/mount.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. + +from fuxi import exceptions +from fuxi.i18n import _ +from fuxi import utils + +from oslo_concurrency import processutils +from oslo_log import log as logging +from oslo_utils import excutils + +proc_mounts_path = '/proc/mounts' + +LOG = logging.getLogger(__name__) + + +class MountInfo(object): + def __init__(self, device, mountpoint, fstype, opts): + self.device = device + self.mountpoint = mountpoint + self.fstype = fstype + self.opts = opts + + def __repr__(self, *args, **kwargs): + return str(self.__dict__) + + +class Mounter(object): + def make_filesystem(self, devpath, fstype): + try: + utils.execute('mkfs', '-t', fstype, '-F', devpath, + run_as_root=True) + except processutils.ProcessExecutionError as e: + msg = _("Unexpected error while make filesystem. " + "Devpath: {0}, " + "Fstype: {1}" + "Error: {2}").format(devpath, fstype, e) + raise exceptions.MakeFileSystemException(msg) + + def mount(self, devpath, mountpoint, fstype=None): + try: + if fstype: + utils.execute('mount', '-t', fstype, devpath, mountpoint, + run_as_root=True) + else: + utils.execute('mount', devpath, mountpoint, + run_as_root=True) + except processutils.ProcessExecutionError as e: + msg = _("Unexpected error while mount block device. " + "Devpath: {0}, " + "Mountpoint: {1} " + "Error: {2}").format(devpath, mountpoint, e) + raise exceptions.MountException(msg) + + def unmount(self, mountpoint): + try: + utils.execute('umount', mountpoint, run_as_root=True) + except processutils.ProcessExecutionError as e: + msg = _("Unexpected err while unmount block device. " + "Mountpoint: {0}, " + "Error: {1}").format(mountpoint, e) + raise exceptions.UnmountException(msg) + + def read_mounts(self, filter_device=(), filter_fstype=()): + """Read all mounted filesystems. + + Read all mounted filesystems except filtered option. + + :param filter_device: Filter for device, the result will not contain + the mounts whose device argument in it. + :param filter_fstype: Filter for mount point. + :return: All mounts. + """ + try: + (out, err) = processutils.execute('cat', proc_mounts_path, + check_exit_code=0) + except processutils.ProcessExecutionError: + msg = _("Failed to read mounts.") + raise exceptions.FileNotFound(msg) + + lines = out.split('\n') + mounts = [] + for line in lines: + if not line: + continue + tokens = line.split() + if len(tokens) < 4: + continue + if tokens[0] in filter_device or tokens[1] in filter_fstype: + continue + mounts.append(MountInfo(device=tokens[0], mountpoint=tokens[1], + fstype=tokens[2], opts=tokens[3])) + return mounts + + def get_mps_by_device(self, devpath): + """Get all mountpoints that device mounted on. + + :param devpath: The path of mount device. + :return: All mountpoints. + :rtype: list + """ + mps = [] + mounts = self.read_mounts() + for m in mounts: + if devpath == m.device: + mps.append(m.mountpoint) + return mps + + +def check_already_mounted(devpath, mountpoint): + """Check that the mount device is mounted on the specific mount point. + + :param devpath: The path of mount deivce. + :param mountpoint: The path of mount point. + :rtype: bool + """ + mounts = Mounter().read_mounts() + for m in mounts: + if devpath == m.device and mountpoint == m.mountpoint: + return True + return False + + +def do_mount(devpath, mountpoint, fstype): + """Execute device mount operation. + + :param devpath: The path of mount device. + :param mountpoint: The path of mount point. + :param fstype: The file system type. + """ + try: + if check_already_mounted(devpath, mountpoint): + return + + mounter = Mounter() + mounter.mount(devpath, mountpoint, fstype) + except exceptions.MountException: + try: + mounter.make_filesystem(devpath, fstype) + mounter.mount(devpath, mountpoint, fstype) + except exceptions.FuxiException as e: + with excutils.save_and_reraise_exception(): + LOG.error(e.message) diff --git a/fuxi/common/state_monitor.py b/fuxi/common/state_monitor.py new file mode 100644 index 0000000..665440d --- /dev/null +++ b/fuxi/common/state_monitor.py @@ -0,0 +1,84 @@ +# 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 time + +from fuxi.common import constants +from fuxi import exceptions +from fuxi.i18n import _LE + +from cinderclient import exceptions as cinder_exception + +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + + +class StateMonitor(object): + """Monitor the status of Volume. + + Because of some volume operation is asynchronous, such as creating Cinder + volume, this volume could be used for next stop util reached an desired + state. + """ + def __init__(self, client, expected_obj, + desired_state, + transient_states=(), + time_limit=constants.MONITOR_STATE_TIMEOUT, + time_delay=1): + self.client = client + self.expected_obj = expected_obj + self.desired_state = desired_state + self.transient_states = transient_states + self.time_limit = time_limit + self.start_time = time.time() + self.time_delay = time_delay + + def _reached_desired_state(self, current_state): + if current_state == self.desired_state: + return True + elif current_state in self.transient_states: + idx = self.transient_states.index(current_state) + if idx > 0: + self.transient_states = self.transient_states[idx:] + return False + else: + msg = _LE("Unexpected state while waiting for volume. " + "Expected Volume: {0}, " + "Expected State: {1}, " + "Reached State: {2}").format(self.expected_obj, + self.desired_state, + current_state) + LOG.error(msg) + raise exceptions.UnexpectedStateException(msg) + + def monitor_cinder_volume(self): + while True: + try: + volume = self.client.volumes.get(self.expected_obj.id) + except cinder_exception.ClientException: + elapsed_time = time.time() - self.start_time + if elapsed_time > self.time_limit: + msg = _LE("Timed out while waiting for volume. " + "Expected Volume: {0}, " + "Expected State: {1}, " + "Elapsed Time: {2}").format(self.expected_obj, + self.desired_state, + elapsed_time) + LOG.error(msg) + raise exceptions.TimeoutException(msg) + raise + + if self._reached_desired_state(volume.status): + return volume + + time.sleep(self.time_delay) diff --git a/fuxi/connector/__init__.py b/fuxi/connector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/connector/cloudconnector/__init__.py b/fuxi/connector/cloudconnector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/connector/cloudconnector/openstack.py b/fuxi/connector/cloudconnector/openstack.py new file mode 100644 index 0000000..efa884a --- /dev/null +++ b/fuxi/connector/cloudconnector/openstack.py @@ -0,0 +1,147 @@ +# 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 os +import time + +from cinderclient import exceptions as cinder_exception +from novaclient import exceptions as nova_exception +from oslo_concurrency import lockutils +from oslo_concurrency import processutils +from oslo_log import log as logging + +from fuxi.common import blockdevice +from fuxi.common import config +from fuxi.common import constants as consts +from fuxi.common import state_monitor +from fuxi.connector import connector +from fuxi import exceptions +from fuxi.i18n import _, _LI, _LW, _LE +from fuxi import utils + +CONF = config.CONF + +LOG = logging.getLogger(__name__) + + +class CinderConnector(connector.Connector): + def __init__(self): + super(CinderConnector, self).__init__() + self.cinderclient = utils.get_cinderclient() + self.novaclient = utils.get_novaclient() + + @lockutils.synchronized('openstack-attach-volume') + def connect_volume(self, volume, **connect_opts): + bdm = blockdevice.BlockerDeviceManager() + ori_devices = bdm.device_scan() + + # Do volume-attach + try: + server_id = connect_opts.get('server_id', None) + if not server_id: + server_id = utils.get_instance_uuid() + + LOG.info(_LI("Start to connect to volume {0}").format(volume)) + nova_volume = self.novaclient.volumes.create_server_volume( + server_id=server_id, + volume_id=volume.id, + device=None) + + volume_monitor = state_monitor.StateMonitor( + self.cinderclient, + nova_volume, + 'in-use', + ('available', 'attaching',)) + attached_volume = volume_monitor.monitor_cinder_volume() + except nova_exception.ClientException as ex: + LOG.error(_LE("Attaching volume {0} to server {1} " + "failed. Error: {2}").format(volume.id, + server_id, ex)) + raise + + # Get all devices on host after do volume-attach, + # and then find attached device. + LOG.info(_LI("After connected to volume, scan the added " + "block device on host")) + curr_devices = bdm.device_scan() + start_time = time.time() + delta_devices = list(set(curr_devices) - set(ori_devices)) + while not delta_devices: + time.sleep(consts.DEVICE_SCAN_TIME_DELAY) + curr_devices = bdm.device_scan() + delta_devices = list(set(curr_devices) - set(ori_devices)) + if time.time() - start_time > consts.DEVICE_SCAN_TIMEOUT: + msg = _("Could not detect added device with " + "limited time") + raise exceptions.FuxiException(msg) + LOG.info(_LI("Get extra added block device {0}" + "").format(delta_devices)) + + for device in delta_devices: + if bdm.get_device_size(device) == volume.size: + device = device.replace('/sys/block', '/dev') + msg = _LI("Find attached device {0} for volume {1} " + "{2}").format(device, + attached_volume.name, + volume) + LOG.info(msg) + + link_path = os.path.join(consts.VOLUME_LINK_DIR, volume.id) + try: + utils.execute('ln', '-s', device, + link_path, + run_as_root=True) + except processutils.ProcessExecutionError as e: + msg = _LE("Error happened when create link file for " + "block device attached by Nova. " + "Error: {0}").format(e) + LOG.error(msg) + raise + return {'path': link_path} + + LOG.warm(_LW("Could not find matched device")) + raise exceptions.NotFound("Not Found Matched Device") + + def disconnect_volume(self, volume, **disconnect_opts): + try: + volume = self.cinderclient.volumes.get(volume.id) + except cinder_exception.ClientException as e: + msg = _LE("Get Volume {0} from Cinder failed").format(volume.id) + LOG.error(msg) + raise + + try: + link_path = self.get_device_path(volume) + utils.execute('rm', '-f', link_path, run_as_root=True) + except processutils.ProcessExecutionError as e: + msg = _LE("Error happened when remove docker volume " + "mountpoint directory. Error: {0}").format(e) + LOG.warn(msg) + + try: + self.novaclient.volumes.delete_server_volume( + utils.get_instance_uuid(), + volume.id) + except nova_exception.ClientException as e: + msg = _LE("Detaching volume {0} failed. " + "Err: {1}").format(volume.id, e) + LOG.error(msg) + raise + + volume_monitor = state_monitor.StateMonitor(self.cinderclient, + volume, + 'available', + ('in-use', 'detaching',)) + return volume_monitor.monitor_cinder_volume() + + def get_device_path(self, volume): + return os.path.join(consts.VOLUME_LINK_DIR, volume.id) diff --git a/fuxi/connector/connector.py b/fuxi/connector/connector.py new file mode 100644 index 0000000..b9f6c7e --- /dev/null +++ b/fuxi/connector/connector.py @@ -0,0 +1,35 @@ +# Copyright 2013 OpenStack Foundation. +# 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 Connector(object): + def __init__(self): + pass + + @abc.abstractmethod + def connect_volume(self, volume, **connect_opts): + pass + + @abc.abstractmethod + def disconnect_volume(self, volume, **disconnect_opts): + pass + + @abc.abstractmethod + def get_device_path(self, volume): + pass diff --git a/fuxi/connector/osbrickconnector.py b/fuxi/connector/osbrickconnector.py new file mode 100644 index 0000000..7ca0cb1 --- /dev/null +++ b/fuxi/connector/osbrickconnector.py @@ -0,0 +1,174 @@ +# 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 os + +from os_brick.initiator import connector +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import excutils + +from fuxi.common import constants as consts +from fuxi.connector import connector as fuxi_connector +from fuxi.i18n import _LI, _LE +from fuxi import utils + +from cinderclient import exceptions as cinder_exception + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + + +def brick_get_connector_properties(multipath=False, enforce_multipath=False): + """Wrapper to automatically set root_helper in brick calls. + + :param multipath: A boolean indicating whether the connector can + support multipath. + :param enforce_multipath: If True, it raises exception when multipath=True + is specified but multipathd is not running. + If False, it falls back to multipath=False + when multipathd is not running. + """ + + root_helper = utils.get_root_helper() + return connector.get_connector_properties(root_helper, + CONF.my_ip, + multipath, + enforce_multipath) + + +def brick_get_connector(protocol, driver=None, + execute=processutils.execute, + use_multipath=False, + device_scan_attempts=3, + *args, **kwargs): + """Wrapper to get a brick connector object. + + This automatically populates the required protocol as well + as the root_helper needed to execute commands. + """ + + root_helper = utils.get_root_helper() + return connector.InitiatorConnector.factory( + protocol, root_helper, + driver=driver, + execute=execute, + use_multipath=use_multipath, + device_scan_attempts=device_scan_attempts, + *args, **kwargs) + + +class CinderConnector(fuxi_connector.Connector): + def __init__(self): + super(CinderConnector, self).__init__() + self.cinderclient = utils.get_cinderclient() + + def _get_connection_info(self, volume_id): + LOG.info(_LI("Get connection info for osbrick connector and use it to " + "connect to volume")) + try: + conn_info = self.cinderclient.volumes.initialize_connection( + volume_id, + brick_get_connector_properties()) + msg = _LI("Get connection information {0}").format(conn_info) + LOG.info(msg) + return conn_info + except cinder_exception.ClientException as e: + msg = _LE("Error happened when initialize connection for volume. " + "Error: {0}").format(e) + LOG.error(msg) + raise + + def _connect_volume(self, volume): + conn_info = self._get_connection_info(volume.id) + + protocol = conn_info['driver_volume_type'] + brick_connector = brick_get_connector(protocol) + device_info = brick_connector.connect_volume(conn_info['data']) + LOG.info(_LI("Get device_info after connect to " + "volume %s") % device_info) + try: + link_path = os.path.join(consts.VOLUME_LINK_DIR, volume.id) + utils.execute('ln', '-s', os.path.realpath(device_info['path']), + link_path, + run_as_root=True) + except processutils.ProcessExecutionError as e: + LOG.error(_LE("Failed to create link for device. %s"), e) + raise + return {'path': link_path, 'iscsi_path': device_info['path']} + + def _disconnect_volume(self, volume): + try: + link_path = self.get_device_path(volume) + utils.execute('rm', '-f', link_path, run_as_root=True) + except processutils.ProcessExecutionError as e: + msg = _LE("Error happened when remove docker volume " + "mountpoint directory. Error: {0}").format(e) + LOG.warn(msg) + + conn_info = self._get_connection_info(volume.id) + + protocol = conn_info['driver_volume_type'] + brick_get_connector(protocol).disconnect_volume(conn_info['data'], + None) + + def connect_volume(self, volume, **connect_opts): + mountpoint = connect_opts.get('mountpoint', None) + host_name = utils.get_hostname() + + try: + self.cinderclient.volumes.reserve(volume) + except cinder_exception.ClientException: + LOG.error(_LE("Reserve volume %s failed"), volume) + raise + + try: + device_info = self._connect_volume(volume) + self.cinderclient.volumes.attach(volume=volume, + instance_uuid=None, + mountpoint=mountpoint, + host_name=host_name) + LOG.info(_LI("Attach volume to this server successfully")) + except Exception: + LOG.error(_LE("Attach volume %s to this server failed"), volume) + with excutils.save_and_reraise_exception(): + try: + self._disconnect_volume(volume) + except Exception: + pass + self.cinderclient.volumes.unreserve(volume) + + return device_info + + def disconnect_volume(self, volume, **disconnect_opts): + self._disconnect_volume(volume) + + attachments = volume.attachments + attachment_uuid = None + for am in attachments: + if am['host_name'].lower() == utils.get_hostname().lower(): + attachment_uuid = am['attachment_id'] + break + try: + self.cinderclient.volumes.detach(volume.id, + attachment_uuid=attachment_uuid) + LOG.info(_LI("Disconnect volume successfully")) + except cinder_exception.ClientException as e: + msg = _LE("Error happened when detach volume {0} {1} from this " + "server. Error: {2}").format(volume.name, volume, e) + LOG.error(msg) + raise + + def get_device_path(self, volume): + return os.path.join(consts.VOLUME_LINK_DIR, volume.id) diff --git a/fuxi/controllers.py b/fuxi/controllers.py new file mode 100644 index 0000000..d1c541b --- /dev/null +++ b/fuxi/controllers.py @@ -0,0 +1,227 @@ +# 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 collections +import flask +import os + +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_utils import importutils + +from fuxi import app +from fuxi import exceptions +from fuxi.i18n import _, _LI, _LW +from fuxi import utils + +CONF = cfg.CONF + +CINDER = 'cinder' + +volume_providers_conf = { + CINDER: 'fuxi.volumeprovider.cinder.Cinder', } + + +def init_app_conf(): + # Init volume providers. + volume_providers = CONF.volume_providers + if not volume_providers: + raise Exception("Must define volume providers in configuration file") + + app.volume_providers = collections.OrderedDict() + for provider in volume_providers: + if provider in volume_providers_conf: + app.volume_providers[provider] = importutils\ + .import_class(volume_providers_conf[provider])() + app.logger.info(_LI("Load volume provider: {0}").format(provider)) + else: + msg = _LW("Could not find volume provider: {0}").format(provider) + app.logger.warn(msg) + if not app.volume_providers: + raise Exception("Not provide at least one effective volume provider") + + # Init volume store directory. + try: + volume_dir = CONF.volume_dir + if not os.path.exists(volume_dir) or not os.path.isdir(volume_dir): + utils.execute('mkdir', '-p', '-m=700', volume_dir, + run_as_root=True) + except processutils.ProcessExecutionError: + raise + + +def get_docker_volume(docker_volume_name): + for provider in app.volume_providers.values(): + try: + return provider.show(docker_volume_name) + except exceptions.NotFound: + pass + return None + + +@app.route('/Plugin.Activate', methods=['POST']) +def plugin_activate(): + app.logger.info(_LI("/Plugin.Activate")) + return flask.jsonify(Implements=[u'VolumeDriver']) + + +@app.route('/VolumeDriver.Create', methods=['POST']) +def volumedriver_create(): + json_data = flask.request.get_json(force=True) + app.logger.info(_LI("Received JSON data {0} for " + "/VolumeDriver.Create").format(json_data)) + + docker_volume_name = json_data.get('Name', None) + volume_opts = json_data.get('Opts', None) or {} + if not docker_volume_name: + msg = _("Request /VolumeDriver.Create need parameter 'Name'") + app.logger.error(msg) + raise exceptions.InvalidInput(msg) + if not isinstance(volume_opts, dict): + msg = _("Request parameter 'Opts' must be dict type") + app.logger.error(msg) + raise exceptions.InvalidInput(msg) + + volume_provider_type = volume_opts.get('volume_provider', None) + if not volume_provider_type: + volume_provider_type = app.volume_providers.keys()[0] + + if volume_provider_type not in app.volume_providers: + msg_fmt = _("Could not find a handler for %(volume_provider_type)s " + "volume") % {'volume_provider_type': volume_provider_type} + app.logger.error(msg_fmt) + return flask.jsonify(Err=msg_fmt) + + # If the volume with the same name already exists in other volume + # provider backend, then raise an error + for vpt, provider in app.volume_providers.items(): + if volume_provider_type != vpt \ + and provider.check_exist(docker_volume_name): + msg_fmt = _("The volume with the same name already exists in " + "other volume provider backend") + app.logger.error(msg_fmt) + return flask.jsonify(Err=msg_fmt) + + # Create if volume does not exist, or attach to this server if needed + # if volume exists in related volume provider. + app.volume_providers[volume_provider_type].create(docker_volume_name, + volume_opts) + + return flask.jsonify(Err=u'') + + +@app.route('/VolumeDriver.Remove', methods=['POST']) +def volumedriver_remove(): + json_data = flask.request.get_json(force=True) + app.logger.info(_LI("Received JSON data {0} for " + "/VolumeDriver.Remove").format(json_data)) + + docker_volume_name = json_data.get('Name', None) + if not docker_volume_name: + msg = _("Request /VolumeDriver.Remove need parameter 'Name'") + app.logger.error(msg) + raise exceptions.InvalidInput(msg) + + for provider in app.volume_providers.values(): + if provider.delete(docker_volume_name): + return flask.jsonify(Err=u'') + + return flask.jsonify(Err=u'') + + +@app.route('/VolumeDriver.Mount', methods=['POST']) +def volumedriver_mount(): + json_data = flask.request.get_json(force=True) + app.logger.info(_LI("Receive JSON data {0} for " + "/VolumeDriver.Mount").format(json_data)) + + docker_volume_name = json_data.get('Name', None) + if not docker_volume_name: + msg = _("Request /VolumeDriver.Mount need parameter 'Name'") + app.logger.error(msg) + raise exceptions.InvalidInput(msg) + + for provider in app.volume_providers.values(): + if provider.check_exist(docker_volume_name): + mountpoint = provider.mount(docker_volume_name) + return flask.jsonify(Mountpoint=mountpoint, Err=u'') + + return flask.jsonify(Err=u'Mount Failed') + + +@app.route('/VolumeDriver.Path', methods=['POST']) +def volumedriver_path(): + json_data = flask.request.get_json(force=True) + app.logger.info(_LI("Receive JSON data {0} for " + "/VolumeDriver.Path").format(json_data)) + + docker_volume_name = json_data.get('Name', None) + if not docker_volume_name: + msg = _("Request /VolumeDriver.Path need parameter 'Name'") + app.logger.error(msg) + raise exceptions.InvalidInput(msg) + + volume = get_docker_volume(docker_volume_name) + if volume is not None: + mountpoint = volume.get('Mountpoint', '') + app.logger.info("Get mountpoint %(mp)s for docker volume %(name)s" + % {'mp': mountpoint, 'name': docker_volume_name}) + return flask.jsonify(Mountpoint=mountpoint, Err=u'') + + app.logger.warn(_LW("Can't find mountpoint for docker volume " + "%(name)s") % {'name': docker_volume_name}) + return flask.jsonify(Err=u'Mountpoint Not Found') + + +@app.route('/VolumeDriver.Unmount', methods=['POST']) +def volumedriver_unmount(): + json_data = flask.request.get_json(force=True) + app.logger.info(_LI('Receive JSON data {0} for ' + 'VolumeDriver.Unmount').format(json_data)) + return flask.jsonify(Err=u'') + + +@app.route('/VolumeDriver.Get', methods=['POST']) +def volumedriver_get(): + json_data = flask.request.get_json(force=True) + app.logger.info(_LI("Receive JSON data {0} for " + "/VolumeDriver.Get").format(json_data)) + + docker_volume_name = json_data.get('Name', None) + if not docker_volume_name: + msg = _("Request /VolumeDriver.Get need parameter 'Name'") + app.logger.error(msg) + raise exceptions.InvalidInput(msg) + + volume = get_docker_volume(docker_volume_name) + if volume is not None: + msg_fmt = _LI("Get docker volume: {0}").format(volume) + app.logger.info(msg_fmt) + return flask.jsonify(Volume=volume, Err=u'') + + app.logger.warn(_LW("Can't find volume {0} from every " + "provider").format(docker_volume_name)) + return flask.jsonify(Err=u'Volume Not Found') + + +@app.route('/VolumeDriver.List', methods=['POST']) +def volumedriver_list(): + app.logger.info(_LI("/VolumeDriver.List")) + docker_volumes = [] + for provider in app.volume_providers.values(): + vs = provider.list() + if vs: + docker_volumes.extend(vs) + + app.logger.info(_LI("Get volumes from volume providers. " + "Volumes: {0}").format(docker_volumes)) + return flask.jsonify(Err=u'', Volumes=docker_volumes) diff --git a/fuxi/exceptions.py b/fuxi/exceptions.py new file mode 100644 index 0000000..14614f0 --- /dev/null +++ b/fuxi/exceptions.py @@ -0,0 +1,60 @@ +# 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 FuxiException(Exception): + """Default Kuryr exception""" + + +class TimeoutException(FuxiException): + """A timeout on waiting for volume to reach destination end state.""" + + +class UnexpectedStateException(FuxiException): + """Unexpected volume state appeared""" + + +class LoopExceeded(FuxiException): + """Raised when ``loop_until`` looped too many times.""" + + +class NotFound(FuxiException): + """The resource could not be found""" + + +class TooManyResources(FuxiException): + """Find too many resources.""" + + +class InvalidInput(FuxiException): + """Request data is invalidate""" + + +class NotMatchedState(FuxiException): + """Current state not match to expected state""" + message = "Current state not match to expected state." + + +class MakeFileSystemException(FuxiException): + """Unexpected error while make file system.""" + + +class MountException(FuxiException): + """Unexpected error while mount device.""" + + +class UnmountException(FuxiException): + """Unexpected error while do umount""" + + +class FileNotFound(FuxiException): + """The expected file not exist""" diff --git a/fuxi/i18n.py b/fuxi/i18n.py new file mode 100644 index 0000000..63693b0 --- /dev/null +++ b/fuxi/i18n.py @@ -0,0 +1,42 @@ +# 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 oslo_i18n + +DOMAIN = "fuxi" + +_translators = oslo_i18n.TranslatorFactory(domain=DOMAIN) + +# The primary translation function using the well-known name "_" +_ = _translators.primary + +# The contextual translation function using the name "_C" +_C = _translators.contextual_form + +# The plural translation function using the name "_P" +_P = _translators.plural_form + +# Translators for log levels. +# +# The abbreviated names are meant to reflect the usual use of a short +# name like '_'. The "L" is for "log" and the other letter comes from +# the level. +_LI = _translators.log_info +_LW = _translators.log_warning +_LE = _translators.log_error +_LC = _translators.log_critical + + +def get_available_languages(): + return oslo_i18n.get_available_languages(DOMAIN) diff --git a/fuxi/opts.py b/fuxi/opts.py new file mode 100644 index 0000000..4adfd1f --- /dev/null +++ b/fuxi/opts.py @@ -0,0 +1,25 @@ +# 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__ = [ + 'list_fuxi_opts', +] + +import itertools + +from fuxi.common import config + + +def list_fuxi_opts(): + return [('DEFAULT', itertools.chain(config.default_opts,)), + ('keystone', itertools.chain(config.keystone_opts,)), + ('cinder', itertools.chain(config.cinder_opts,)), ] diff --git a/fuxi/server.py b/fuxi/server.py new file mode 100644 index 0000000..eed1fd5 --- /dev/null +++ b/fuxi/server.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. + +import sys + +from fuxi import app +from fuxi.common import config +from fuxi import controllers + +from oslo_log import log as logging + + +def start(): + config.init(sys.argv[1:]) + logging.setup(config.CONF, 'fuxi') + + controllers.init_app_conf() + + port = config.CONF.fuxi_port + app.run("0.0.0.0", port, + debug=config.CONF.debug, + threaded=config.CONF.threaded) diff --git a/fuxi/tests/base.py b/fuxi/tests/base.py index 1c30cdb..ecf2a4c 100644 --- a/fuxi/tests/base.py +++ b/fuxi/tests/base.py @@ -21,3 +21,5 @@ from oslotest import base class TestCase(base.BaseTestCase): """Test case base class for all unit tests.""" + def setUp(self): + super(TestCase, self).setUp() diff --git a/fuxi/tests/common/__init__.py b/fuxi/tests/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/tests/common/test_mount.py b/fuxi/tests/common/test_mount.py new file mode 100644 index 0000000..eb91c03 --- /dev/null +++ b/fuxi/tests/common/test_mount.py @@ -0,0 +1,120 @@ +# 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 fuxi.common import mount +from fuxi import exceptions +from fuxi.tests import base + + +class FakeMounter(object): + def __init__(self, mountinfo=None): + self.mountinfo = "/dev/0 /path/to/0 type0 flags 0 0\n" \ + "/dev/1 /path/to/1 type1 flags 0 0\n" \ + "/dev/2 /path/to/2 type2 flags,1,2=3 0 0\n" \ + if not mountinfo else mountinfo + + def mount(self, devpath, mountpoint, fstype=None): + if not fstype: + fstype = 'ext4' + self.mountinfo += ' '.join([devpath, mountpoint, fstype, + 'flags', '0', '0\n']) + + def unmount(self, mountpoint): + mounts = self.read_mounts() + ori_len = len(mounts) + for m in mounts: + if m.mountpoint == mountpoint: + mounts.remove(m) + if ori_len != len(mounts): + self.mountinfo = ''.join([' '.join([m.device, m.mountpoint, + m.fstype, m.opts, + '0', '0\n']) + for m in mounts]) + else: + raise exceptions.UnmountException() + + def read_mounts(self, filter_device=(), filter_fstype=()): + lines = self.mountinfo.split('\n') + mounts = [] + for line in lines: + if not line: + continue + tokens = line.split() + if len(tokens) < 4: + continue + if tokens[0] in filter_device or tokens[1] in filter_fstype: + continue + mounts.append(mount.MountInfo(device=tokens[0], + mountpoint=tokens[1], + fstype=tokens[2], opts=tokens[3])) + return mounts + + def get_mps_by_device(self, devpath): + mps = [] + mounts = self.read_mounts() + for m in mounts: + if devpath in m.device: + mps.append(m.mountpoint) + return mps + + +def check_already_mounted(devpath, mountpoint): + mounts = FakeMounter().read_mounts() + for m in mounts: + if m.device == devpath and m.mountpoint == mountpoint: + return True + return False + + +class TestMounter(base.TestCase): + def setUp(self): + super(TestMounter, self).setUp() + + def test_mount(self): + fake_devpath = '/dev/3' + fake_mp = '/path/to/3' + fake_fstype = 'ext4' + fake_mounter = FakeMounter() + fake_mounter.mount(fake_devpath, fake_mp, fake_fstype) + fake_mountinfo = "/dev/0 /path/to/0 type0 flags 0 0\n" \ + "/dev/1 /path/to/1 type1 flags 0 0\n" \ + "/dev/2 /path/to/2 type2 flags,1,2=3 0 0\n" \ + "/dev/3 /path/to/3 ext4 flags 0 0\n" + self.assertEqual(fake_mountinfo, fake_mounter.mountinfo) + + def test_unmount(self): + fake_mp = '/path/to/2' + fake_mounter = FakeMounter() + fake_mounter.unmount(fake_mp) + fake_mountinfo = "/dev/0 /path/to/0 type0 flags 0 0\n" \ + "/dev/1 /path/to/1 type1 flags 0 0\n" + self.assertEqual(fake_mountinfo, fake_mounter.mountinfo) + + def test_read_mounts(self): + fake_mounts = [str(mount.MountInfo('/dev/0', '/path/to/0', + 'type0', 'flags')), + str(mount.MountInfo('/dev/1', '/path/to/1', + 'type1', 'flags')), + str(mount.MountInfo('/dev/2', '/path/to/2', + 'type2', 'flags,1,2=3'))] + mounts = [str(m) for m in FakeMounter().read_mounts()] + self.assertEqual(len(fake_mounts), len(mounts)) + for m in mounts: + self.assertIn(m, fake_mounts) + + def test_get_mps_by_device(self): + self.assertEqual(['/path/to/0'], + FakeMounter().get_mps_by_device('/dev/0')) + + def test_check_alread_mounted(self): + self.assertTrue(check_already_mounted('/dev/0', '/path/to/0')) + self.assertFalse(check_already_mounted('/dev/0', '/path/to/1')) diff --git a/fuxi/tests/common/test_state_monitor.py b/fuxi/tests/common/test_state_monitor.py new file mode 100644 index 0000000..a9a597b --- /dev/null +++ b/fuxi/tests/common/test_state_monitor.py @@ -0,0 +1,81 @@ +# 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 mock + +from cinderclient import exceptions as cinder_exception + +from fuxi.common import state_monitor +from fuxi import exceptions +from fuxi.tests import base, fake_client, fake_object + + +class TestStateMonitor(base.TestCase): + def setUp(self): + super(TestStateMonitor, self).setUp() + + def test_monitor_cinder_volume(self): + fake_cinder_client = fake_client.FakeCinderClient() + fake_cinder_volume = fake_object.FakeCinderVolume(status='available') + fake_desired_state = 'in-use' + fake_transient_states = ('in-use',) + fake_time_limit = 0 + fake_state_monitor = state_monitor.StateMonitor(fake_cinder_client, + fake_cinder_volume, + fake_desired_state, + fake_transient_states, + fake_time_limit) + + fake_desired_volume = fake_object.FakeCinderVolume(status='in-use') + with mock.patch.object(fake_client.FakeCinderClient.Volumes, 'get', + return_value=fake_desired_volume): + self.assertEqual(fake_desired_volume, + fake_state_monitor.monitor_cinder_volume()) + + def test_monitor_cinder_volume_get_failed(self): + fake_cinder_client = fake_client.FakeCinderClient() + fake_cinder_volume = fake_object.FakeCinderVolume(status='available') + + with mock.patch('fuxi.tests.fake_client.FakeCinderClient.Volumes.get', + side_effect=cinder_exception.ClientException(404)): + fake_state_monitor = state_monitor.StateMonitor(fake_cinder_client, + fake_cinder_volume, + None, None, -1) + self.assertRaises(exceptions.TimeoutException, + fake_state_monitor.monitor_cinder_volume) + + with mock.patch('fuxi.tests.fake_client.FakeCinderClient.Volumes.get', + side_effect=cinder_exception.ClientException(404)): + fake_state_monitor = state_monitor.StateMonitor(fake_cinder_client, + fake_cinder_volume, + None, None) + self.assertRaises(cinder_exception.ClientException, + fake_state_monitor.monitor_cinder_volume) + + def test_monitor_cinder_volume_unexpected_state(self): + fake_cinder_client = fake_client.FakeCinderClient() + fake_cinder_volume = fake_object.FakeCinderVolume(status='available') + fake_desired_state = 'in-use' + fake_transient_states = ('in-use',) + fake_time_limit = 0 + + fake_state_monitor = state_monitor.StateMonitor(fake_cinder_client, + fake_cinder_volume, + fake_desired_state, + fake_transient_states, + fake_time_limit) + fake_desired_volume = fake_object.FakeCinderVolume(status='attaching') + + with mock.patch.object(fake_client.FakeCinderClient.Volumes, 'get', + return_value=fake_desired_volume): + self.assertRaises(exceptions.UnexpectedStateException, + fake_state_monitor.monitor_cinder_volume) diff --git a/fuxi/tests/connector/__init__.py b/fuxi/tests/connector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/tests/connector/cloudconnector/__init__.py b/fuxi/tests/connector/cloudconnector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/tests/connector/cloudconnector/test_openstack.py b/fuxi/tests/connector/cloudconnector/test_openstack.py new file mode 100644 index 0000000..a2e18df --- /dev/null +++ b/fuxi/tests/connector/cloudconnector/test_openstack.py @@ -0,0 +1,105 @@ +# 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 mock +import os + +from fuxi.common import constants +from fuxi.common import state_monitor +from fuxi.connector.cloudconnector import openstack +from fuxi import utils +from fuxi.tests import base, fake_client, fake_object + +from cinderclient import exceptions as cinder_exception +from novaclient import exceptions as nova_exception + + +def mock_list_with_attach_to_this(cls, search_opts={}): + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': None, + u'device': None, + u'id': u'123'}] + return [fake_object.FakeCinderVolume(name='fake-vol1', + attachments=attachments)] + + +def mock_list_with_attach_to_other(cls, search_opts={}): + attachments = [{u'server_id': u'1234', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': None, + u'device': None, + u'id': u'123'}] + return [fake_object.FakeCinderVolume(name='fake-vol1', + attachments=attachments)] + + +def mock_get_mountpoint_for_device(devpath, mountpoint): + return '' + + +class TestCinderConnector(base.TestCase): + def setUp(self): + base.TestCase.setUp(self) + self.connector = openstack.CinderConnector() + self.connector.cinderclient = fake_client.FakeCinderClient() + self.connector.novaclient = fake_client.FakeNovaClient() + + def test_connect_volume(self): + pass + + @mock.patch.object(utils, 'get_instance_uuid', return_value='fake-123') + @mock.patch.object(utils, 'execute') + @mock.patch.object(state_monitor.StateMonitor, 'monitor_cinder_volume', + return_value=None) + def test_disconnect_volume(self, mock_inst_id, mock_execute, mock_monitor): + fake_cinder_volume = fake_object.FakeCinderVolume() + result = self.connector.disconnect_volume(fake_cinder_volume) + self.assertIsNone(result) + + @mock.patch('fuxi.tests.fake_client.FakeCinderClient.Volumes.get', + side_effect=cinder_exception.ClientException(404)) + @mock.patch.object(utils, 'execute') + @mock.patch.object(state_monitor.StateMonitor, + 'monitor_cinder_volume') + def test_disconnect_volume_for_not_found(self, mock_get, mock_execute, + mocK_monitor): + fake_cinder_volume = fake_object.FakeCinderVolume() + self.assertRaises(cinder_exception.ClientException, + self.connector.disconnect_volume, + fake_cinder_volume) + + @mock.patch('fuxi.tests.fake_client.FakeNovaClient.Volumes' + '.delete_server_volume', + side_effect=nova_exception.ClientException(500)) + @mock.patch.object(utils, 'get_instance_uuid', return_value='fake-123') + @mock.patch.object(utils, 'execute') + @mock.patch.object(state_monitor.StateMonitor, + 'monitor_cinder_volume') + def test_disconnect_volume_for_delete_server_volume_failed(self, + mock_delete, + mock_inst_id, + mock_execute, + mock_monitor): + fake_cinder_volume = fake_object.FakeCinderVolume() + self.assertRaises(nova_exception.ClientException, + self.connector.disconnect_volume, + fake_cinder_volume) + + def test_get_device_path(self): + fake_cinder_volume = fake_object.FakeCinderVolume() + fake_devpath = os.path.join(constants.VOLUME_LINK_DIR, + fake_cinder_volume.id) + self.assertEqual(fake_devpath, + self.connector.get_device_path(fake_cinder_volume)) diff --git a/fuxi/tests/connector/test_osbrickconnector.py b/fuxi/tests/connector/test_osbrickconnector.py new file mode 100644 index 0000000..8f34d7b --- /dev/null +++ b/fuxi/tests/connector/test_osbrickconnector.py @@ -0,0 +1,168 @@ +# 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 mock +import os +import platform +import socket +import sys + +from fuxi.common import constants +from fuxi.connector import osbrickconnector +from fuxi.tests import base, fake_client, fake_object +from fuxi import utils + +from cinderclient import exceptions as cinder_exception + +from oslo_concurrency import processutils + + +def mock_get_connector_properties(multipath=False, enforce_multipath=False): + props = {} + props['host'] = socket.gethostname() + props['initiator'] = 'iqn.1993-08.org.debian:01:b57cc344932' + props['platform'] = platform.machine() + props['os_type'] = sys.platform + return props + + +def mock_list_with_attach_to_this(cls, search_opts={}): + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + return [fake_object.FakeCinderVolume(name='fake-vol1', + attachments=attachments)] + + +def mock_list_with_attach_to_other(cls, search_opts={}): + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname() + u'other', + u'device': None, + u'id': u'123'}] + return [fake_object.FakeCinderVolume(name='fake-vol1', + attachments=attachments)] + + +def mock_get_mountpoint_for_device(devpath, mountpoint): + return '' + + +class TestCinderConnector(base.TestCase): + def setUp(self): + base.TestCase.setUp(self) + self.connector = osbrickconnector.CinderConnector() + self.connector.cinderclient = fake_client.FakeCinderClient() + + def test_connect_volume(self): + fake_cinder_volume = fake_object.FakeCinderVolume() + self.connector._connect_volume = mock.MagicMock() + self.connector.connect_volume(fake_cinder_volume) + self.assertEqual(1, len(fake_cinder_volume.attachments)) + + @mock.patch.object(osbrickconnector, 'brick_get_connector', + return_value=fake_client.FakeOSBrickConnector()) + @mock.patch.object(utils, 'execute') + def test_disconnect_volume(self, mock_brick_connector, mock_execute): + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + fake_cinder_volume = \ + fake_object.FakeCinderVolume(attachments=attachments) + + self.connector._get_connection_info = mock.MagicMock() + self.connector.cinderclient.volumes.detach = mock.MagicMock() + self.assertIsNone(self.connector.disconnect_volume(fake_cinder_volume)) + + @mock.patch.object(osbrickconnector, 'brick_get_connector_properties', + mock_get_connector_properties) + @mock.patch.object(utils, 'execute') + @mock.patch('fuxi.tests.fake_client.FakeCinderClient.Volumes' + '.initialize_connection', + side_effect=cinder_exception.ClientException(500)) + def test_disconnect_volume_no_connection_info(self, mock_execute, + mock_init_conn): + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + fake_cinder_volume = \ + fake_object.FakeCinderVolume(attachments=attachments) + self.assertRaises(cinder_exception.ClientException, + self.connector.disconnect_volume, + fake_cinder_volume) + + @mock.patch.object(osbrickconnector, 'brick_get_connector', + return_value=fake_client.FakeOSBrickConnector()) + @mock.patch.object(osbrickconnector.CinderConnector, + '_get_connection_info', + return_value={'driver_volume_type': 'fake_proto', + 'data': {'path': '/dev/0'}}) + @mock.patch.object(utils, 'execute') + @mock.patch('fuxi.tests.fake_client.FakeOSBrickConnector' + '.disconnect_volume', + side_effect=processutils.ProcessExecutionError()) + def test_disconnect_volume_osbrick_disconnect_failed(self, mock_connector, + mock_init_conn, + mock_execute, + mock_disconnect_vol): + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + fake_cinder_volume = \ + fake_object.FakeCinderVolume(attachments=attachments) + self.assertRaises(processutils.ProcessExecutionError, + self.connector.disconnect_volume, + fake_cinder_volume) + + @mock.patch('fuxi.tests.fake_client.FakeCinderClient.Volumes.detach', + side_effect=cinder_exception.ClientException(500)) + @mock.patch.object(osbrickconnector, 'brick_get_connector', + return_value=fake_client.FakeOSBrickConnector()) + @mock.patch.object(utils, 'execute') + @mock.patch.object(osbrickconnector.CinderConnector, + '_get_connection_info', + return_value={'driver_volume_type': 'fake_proto', + 'data': {'path': '/dev/0'}}) + def test_disconnect_volume_detach_failed(self, mock_detach, + mock_brick_connector, + mock_execute, + mock_conn_info): + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + fake_cinder_volume = \ + fake_object.FakeCinderVolume(attachments=attachments) + self.assertRaises(cinder_exception.ClientException, + self.connector.disconnect_volume, + fake_cinder_volume) + + def test_get_device_path(self): + fake_cinder_volume = fake_object.FakeCinderVolume() + self.assertEqual(os.path.join(constants.VOLUME_LINK_DIR, + fake_cinder_volume.id), + self.connector.get_device_path(fake_cinder_volume)) diff --git a/fuxi/tests/fake_client.py b/fuxi/tests/fake_client.py new file mode 100644 index 0000000..b7d4e08 --- /dev/null +++ b/fuxi/tests/fake_client.py @@ -0,0 +1,88 @@ +# 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 fuxi.tests import fake_object + +from cinderclient import exceptions as cinder_exception + + +class FakeCinderClient(object): + class Volumes(object): + def get(self, volume_id): + return fake_object.FakeCinderVolume(id=volume_id) + + def list(self, search_opts={}): + return [fake_object.FakeCinderVolume(name='fake-vol1')] + + def create(self, *args, **kwargs): + return fake_object.FakeCinderVolume(**kwargs) + + def delete(self, volume_id): + return + + def attach(self, volume, instance_uuid, mountpoint, host_name): + if not instance_uuid and not host_name: + raise cinder_exception.ClientException + + attachment = {u'server_id': instance_uuid, + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': host_name, + u'device': None, + u'id': u'123'} + + volume.attachments.append(attachment) + return volume + + def detach(self, volume_id, attachment_uuid): + pass + + def initialize_connection(self, volume, connector): + return {'data': {}} + + def reserve(self, volume): + return + + def update(self, volume, **kwargs): + for key, value in kwargs.items(): + if hasattr(volume, key): + setattr(volume, key, value) + + def set_metadata(self, volume, metadata): + md = volume.metadata + md.update(metadata) + + def __getattr__(self, item): + return None + + def __init__(self): + self.volumes = self.Volumes() + + +class FakeNovaClient(object): + class Volumes(object): + def create_server_volume(self, volume_id): + pass + + def delete_server_volume(self, server_id, volume_id): + return None + + def __init__(self): + self.volumes = self.Volumes() + + +class FakeOSBrickConnector(object): + def connect_volume(self, connection_properties): + pass + + def disconnect_volume(self, connection_properties, device_info): + pass diff --git a/fuxi/tests/fake_object.py b/fuxi/tests/fake_object.py new file mode 100644 index 0000000..f3028f9 --- /dev/null +++ b/fuxi/tests/fake_object.py @@ -0,0 +1,51 @@ +# 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 copy + +DEFAULT_VOLUME_ID = 'efd46583-4bf7-40d5-a027-2ee3dbe74f56' +DEFAULT_VOLUME_NAME = 'fake_vol' + +base_cinder_volume = { + 'attachments': [], + 'availability_zone': 'nova', + 'id': DEFAULT_VOLUME_ID, + 'size': 15, + 'display_name': DEFAULT_VOLUME_NAME, + 'metadata': { + 'readonly': 'False', + 'volume_from': 'fuxi', + 'fstype': 'ext4', + }, + 'status': 'available', + 'multiattach': 'false', + 'volume_type': 'lvmdriver-1', +} + + +class FakeCinderVolume(object): + def __init__(self, **kwargs): + if 'name' in kwargs: + kwargs['display_name'] = kwargs.pop('name') + volume = (copy.deepcopy(base_cinder_volume)) + volume.update(kwargs) + + for key, value in volume.items(): + setattr(self, key, value) + + def get_name(self): + return self.display_name + + def set_name(self, name): + self.display_name = name + + name = property(get_name, set_name) diff --git a/fuxi/tests/test_fuxi.py b/fuxi/tests/test_fuxi.py index 13a6711..3fb0dd3 100644 --- a/fuxi/tests/test_fuxi.py +++ b/fuxi/tests/test_fuxi.py @@ -18,11 +18,321 @@ test_fuxi Tests for `fuxi` module. """ +import collections +import mock +import unittest +from fuxi import app +from fuxi.common import config +from fuxi.controllers import volume_providers_conf +from fuxi import exceptions from fuxi.tests import base +from oslo_serialization import jsonutils + + +def fake_mountpoint(name): + volume_dir = config.CONF.volume_dir.rstrip('/') + return ''.join((volume_dir, name)) + + +def fake_volume(name): + volume_dir = config.CONF.volume_dir.rstrip('/') + return {'Name': name, 'Mountpoint': ''.join((volume_dir, name))} + + +class FakeProvider(object): + def __init__(self, volume_provider_type): + self.volume_provider_type = volume_provider_type + + def create(self, docker_volume_name, volume_opts): + pass + + def delete(self, docker_volume_name): + pass + + def list(self): + pass + + def path(self, docker_volume_name): + pass + + def show(self, docker_volume_name): + pass + + def mount(self, docker_volume_name): + pass + + def unmount(self, docker_volume_name): + pass + + def check_exist(self, docker_volume_name): + return False + class TestFuxi(base.TestCase): + def setUp(self): + super(TestFuxi, self).setUp() + app.config['DEBUG'] = True + app.config['TESTING'] = True + self.app = app.test_client() - def test_something(self): - pass + def volume_providers_setup(self, volume_provider_types): + if not volume_provider_types: + raise Exception + + app.volume_providers = collections.OrderedDict() + for vpt in volume_provider_types: + if vpt in volume_providers_conf: + app.volume_providers[vpt] = FakeProvider(vpt) + + def test_plugin_activate(self): + response = self.app.post('/Plugin.Activate') + fake_response = { + u'Implements': [u'VolumeDriver'] + } + self.assertEqual(200, response.status_code) + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_create(self): + self.volume_providers_setup(['cinder']) + fake_request = { + u'Name': u'test-vol', + u'Opts': {u'size': u'1'}, + } + for provider in app.volume_providers.values(): + provider.check_exist = mock.MagicMock() + provider.check_exist.return_value = False + provider.create = mock.MagicMock() + + response = self.app.post('/VolumeDriver.Create', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Err': u'' + } + + self.assertEqual(200, response.status_code) + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_create_without_name(self): + self.volume_providers_setup(['cinder']) + fake_request = {u'Opts': {}} + response = self.app.post('VolumeDriver.Create', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + self.assertEqual(500, response.status_code) + self.assertIsNotNone(jsonutils.loads(response.data)) + + def test_volumedriver_create_with_invalid_opts(self): + self.volume_providers_setup(['cinder']) + fake_request = {u'Name': u'test-vol', u'Opts': u'invalid'} + response = self.app.post('VolumeDriver.Create', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + self.assertEqual(500, response.status_code) + self.assertIsNotNone(jsonutils.loads(response.data)) + + def test_volumedriver_create_invalid_volume_provider(self): + self.volume_providers_setup(['cinder']) + fake_request = { + u'Name': u'test-vol', + u'Opts': {u'size': u'1', + u'volume_provider': u'provider'}} + for provider in app.volume_providers.values(): + provider.check_exist = mock.MagicMock() + provider.check_exist.return_value = False + provider.create = mock.MagicMock() + + response = self.app.post('VolumeDriver.Create', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Err': u'' + } + self.assertEqual(200, response.status_code) + self.assertNotEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_remove(self): + self.volume_providers_setup(['cinder']) + fake_request = { + u'Name': u'test-vol' + } + for provider in app.volume_providers.values(): + provider.delete = mock.MagicMock() + provider.delete.return_value = True + + response = self.app.post('/VolumeDriver.Remove', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Err': u'' + } + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_remove_with_volume_not_exist(self): + self.volume_providers_setup(['cinder']) + fake_request = { + u'Name': u'test-vol', + } + for provider in app.volume_providers.values(): + provider.delete = mock.MagicMock() + provider.delete.return_value = False + + response = self.app.post('/VolumeDriver.Remove', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Err': u'' + } + self.assertEqual(200, response.status_code) + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_mount(self): + self.volume_providers_setup(['cinder']) + fake_name = u'test-vol' + fake_request = { + u'Name': fake_name + } + + for provider in app.volume_providers.values(): + provider.check_exist = mock.MagicMock() + provider.check_exist.return_value = True + provider.mount = mock.MagicMock() + provider.mount.return_value = fake_mountpoint(fake_name) + + response = self.app.post('/VolumeDriver.Mount', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Mountpoint': fake_mountpoint(fake_name), + u'Err': u'' + } + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_mount_with_volume_not_exist(self): + self.volume_providers_setup(['cinder']) + fake_name = u'test-vol' + fake_request = { + u'Name': fake_name, + } + for provider in app.volume_providers.values(): + provider.check_exit = mock.MagicMock() + provider.check_exit.return_value = False + response = self.app.post('/VolumeDriver.Mount', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Mountpoint': fake_mountpoint(fake_name), + u'Err': u'' + } + self.assertEqual(200, response.status_code) + self.assertNotEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_path(self): + self.volume_providers_setup(['cinder']) + fake_name = u'test-vol' + fake_request = { + u'Name': fake_name + } + for provider in app.volume_providers.values(): + provider.show = mock.MagicMock() + provider.show.return_value = fake_volume(fake_name) + + response = self.app.post('/VolumeDriver.Path', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Mountpoint': fake_mountpoint(fake_name), + u'Err': u'' + } + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_path_with_volume_not_exist(self): + self.volume_providers_setup(['cinder']) + fake_docker_volume_name = u'test-vol' + fake_request = { + u'Name': fake_docker_volume_name + } + for provider in app.volume_providers.values(): + provider.show = mock.MagicMock(side_effect=exceptions.NotFound) + + response = self.app.post('/VolumeDriver.Path', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Err': u'Mountpoint Not Found' + } + self.assertEqual(200, response.status_code) + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_unmount(self): + self.volume_providers_setup(['cinder']) + fake_request = { + u'Name': u'test-vol' + } + response = self.app.post('/VolumeDriver.Unmount', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Err': u'' + } + self.assertEqual(200, response.status_code) + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_get(self): + self.volume_providers_setup(['cinder']) + fake_name = u'test-vol' + fake_request = { + u'Name': fake_name + } + for provider in app.volume_providers.values(): + provider.show = mock.MagicMock() + provider.show.return_value = fake_volume(fake_name) + + response = self.app.post('/VolumeDriver.Get', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Volume': {u'Name': fake_name, + u'Mountpoint': fake_mountpoint(fake_name)}, + u'Err': u'' + } + self.assertEqual(200, response.status_code) + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_get_with_volume_not_exist(self): + self.volume_providers_setup(['cinder']) + fake_docker_volume_name = u'test-vol' + fake_request = { + u'Name': fake_docker_volume_name + } + for provider in app.volume_providers.values(): + provider.show = mock.MagicMock(side_effect=exceptions.NotFound()) + + response = self.app.post('/VolumeDriver.Get', + content_type='application/json', + data=jsonutils.dumps(fake_request)) + fake_response = { + u'Err': u'Volume Not Found' + } + self.assertEqual(200, response.status_code) + self.assertEqual(fake_response, jsonutils.loads(response.data)) + + def test_volumedriver_list(self): + self.volume_providers_setup(['cinder']) + for provider in app.volume_providers.values(): + provider.list = mock.MagicMock() + provider.list.return_value = [] + + response = self.app.post('/VolumeDriver.List', + content_type='application/json') + + fake_response = { + u'Volumes': [], + u'Err': u'' + } + self.assertEqual(fake_response, jsonutils.loads(response.data)) + +if __name__ == '__main__': + unittest.main() diff --git a/fuxi/tests/volumeprovider/__init__.py b/fuxi/tests/volumeprovider/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/tests/volumeprovider/test_cinder.py b/fuxi/tests/volumeprovider/test_cinder.py new file mode 100644 index 0000000..d205c78 --- /dev/null +++ b/fuxi/tests/volumeprovider/test_cinder.py @@ -0,0 +1,286 @@ +# 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 mock import mock +import os +import tempfile + +from fuxi.common import config +from fuxi.common import constants as consts +from fuxi.common import mount +from fuxi import exceptions +from fuxi.tests import base, fake_client, fake_object +from fuxi import utils +from fuxi.volumeprovider import cinder + +from cinderclient import exceptions as cinder_exception + +volume_link_dir = consts.VOLUME_LINK_DIR +DEFAULT_VOLUME_ID = fake_object.DEFAULT_VOLUME_ID + +CONF = config.CONF + +LOG = logging.getLogger(__name__) + + +class FakeCinderConnector(object): + def __init__(self): + pass + + def connect_volume(self, volume, **connect_opts): + return {'path': os.path.join(volume_link_dir, volume.id)} + + def disconnect_volume(self, volume, **disconnect_opts): + pass + + def get_device_path(self, volume): + return os.path.join(volume_link_dir, volume.id) + + +def mock_connector(cls): + return FakeCinderConnector() + + +def mock_monitor_cinder_volume(cls): + return cls.expected_obj + + +def mock_device_path_for_delete(cls, volume): + return volume.id + + +class TestCinder(base.TestCase): + volume_provider_type = 'cinder' + + def setUp(self): + base.TestCase.setUp(self) + self.cinderprovider = cinder.Cinder() + self.cinderprovider.cinderclient = fake_client.FakeCinderClient() + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(None, consts.UNKNOWN)) + def test_create_with_volume_not_exist(self, mock_docker_volume): + self.assertEqual(os.path.join(volume_link_dir, DEFAULT_VOLUME_ID), + self.cinderprovider.create('fake-vol', {})['path']) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + def test_create_with_volume_attach_to_this(self): + fake_server_id = 'fake_server_123' + fake_host_name = 'attached_to_this' + fake_volume_args = {'id': DEFAULT_VOLUME_ID, + 'status': 'in-use', + 'attachments': [{'server_id': fake_server_id, + 'host_name': fake_host_name}] + } + fake_cinder_volume = fake_object.FakeCinderVolume(**fake_volume_args) + self.cinderprovider._get_docker_volume = mock.MagicMock() + self.cinderprovider._get_docker_volume.return_value \ + = (fake_cinder_volume, + consts.ATTACH_TO_THIS) + self.cinderprovider.cinderclient.volumes.get = mock.MagicMock() + self.cinderprovider.cinderclient.volumes.get.return_value = \ + fake_cinder_volume + fake_result = self.cinderprovider.create('fake-vol', {}) + self.assertEqual(os.path.join(volume_link_dir, DEFAULT_VOLUME_ID), + fake_result['path']) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + def test_create_with_volume_no_attach(self): + fake_cinder_volume = fake_object.FakeCinderVolume() + self.cinderprovider._get_docker_volume = mock.MagicMock() + self.cinderprovider._get_docker_volume.return_value \ + = (fake_cinder_volume, + consts.NOT_ATTACH) + fake_result = self.cinderprovider.create('fake-vol', {}) + self.assertEqual(os.path.join(volume_link_dir, DEFAULT_VOLUME_ID), + fake_result['path']) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(fake_object.FakeCinderVolume( + multiattach=True), consts.ATTACH_TO_OTHER)) + def test_create_with_multiable_vol_attached_to_other(self, + mock_docker_volume): + self.assertEqual(os.path.join(volume_link_dir, + fake_object.DEFAULT_VOLUME_ID), + self.cinderprovider.create('fake-vol', {})['path']) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(fake_object.FakeCinderVolume( + multiattach=False), consts.ATTACH_TO_OTHER)) + def test_create_with_volume_attached_to_other(self, mock_docker_volume): + self.assertRaises(exceptions.FuxiException, + self.cinderprovider.create, + 'fake-vol', + {}) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(utils, 'execute') + @mock.patch.object(FakeCinderConnector, + 'get_device_path', + mock_device_path_for_delete) + def test_delete(self, mock_execute): + fd, tmpfname = tempfile.mkstemp() + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + + self.cinderprovider._get_docker_volume = mock.MagicMock() + self.cinderprovider._get_docker_volume.return_value = ( + fake_object.FakeCinderVolume(id=tmpfname, + attachments=attachments), + consts.ATTACH_TO_THIS) + self.cinderprovider._delete_volume = mock.MagicMock() + + self.assertTrue(self.cinderprovider.delete('fake-vol')) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(fake_object.FakeCinderVolume(status=None), + None)) + def test_delete_not_match_state(self, mock_docker_volume): + self.assertRaises(exceptions.NotMatchedState, + self.cinderprovider.delete, + 'fake-vol') + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(utils, 'execute') + @mock.patch.object(FakeCinderConnector, + 'get_device_path', + mock_device_path_for_delete) + @mock.patch('fuxi.tests.fake_client.FakeCinderClient.Volumes.delete', + side_effect=cinder_exception.ClientException(500)) + def test_delete_failed(self, mock_execute, mock_delete): + fd, tmpfname = tempfile.mkstemp() + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + + self.cinderprovider._get_docker_volume = mock.MagicMock() + self.cinderprovider._get_docker_volume.return_value = ( + fake_object.FakeCinderVolume(id=tmpfname, + attachments=attachments), + consts.ATTACH_TO_THIS) + + self.assertRaises(cinder_exception.ClientException, + self.cinderprovider.delete, + 'fake-vol') + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(utils, 'execute') + @mock.patch.object(FakeCinderConnector, + 'get_device_path', + mock_device_path_for_delete) + def test_delete_timeout(self, mock_execute): + consts.DESTROY_VOLUME_TIMEOUT = 4 + fd, tmpfname = tempfile.mkstemp() + attachments = [{u'server_id': u'123', + u'attachment_id': u'123', + u'attached_at': u'2016-05-20T09:19:57.000000', + u'host_name': utils.get_hostname(), + u'device': None, + u'id': u'123'}] + + self.cinderprovider._get_docker_volume = mock.MagicMock() + self.cinderprovider._get_docker_volume.return_value = ( + fake_object.FakeCinderVolume(id=tmpfname, + attachments=attachments), + consts.ATTACH_TO_THIS) + + self.assertRaises(exceptions.TimeoutException, + self.cinderprovider.delete, + 'fake-vol') + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + def test_list(self): + docker_volumes = self.cinderprovider.list() + self.assertEqual(docker_volumes, []) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch('fuxi.tests.fake_client.FakeCinderClient.Volumes.list', + side_effect=cinder_exception.ClientException(500)) + def test_list_failed(self, mock_list): + self.assertRaises(cinder_exception.ClientException, + self.cinderprovider.list) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(utils, 'execute') + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(fake_object.FakeCinderVolume(), + consts.ATTACH_TO_THIS)) + def test_show_state_attach_to_this(self, mock_execute, mock_docker_volume): + self.assertEqual({'Name': 'fake-vol', 'Mountpoint': ''}, + self.cinderprovider.show('fake-vol')) + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(fake_object.FakeCinderVolume( + status='unknown'), consts.UNKNOWN)) + def test_show_state_unknown(self, mock_docker_volume): + self.assertRaises(exceptions.NotFound, + self.cinderprovider.show, + 'fake-vol') + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(fake_object.FakeCinderVolume(status=None), + None)) + def test_show_state_not_match(self, mock_docker_volume): + self.assertRaises(exceptions.FuxiException, + self.cinderprovider.show, + 'fake-vol') + + @mock.patch.object(cinder.Cinder, '_get_connector', mock_connector) + @mock.patch.object(cinder.Cinder, '_get_docker_volume', + return_value=(fake_object.FakeCinderVolume( + name='fake-vol', + status='in-use'), consts.ATTACH_TO_THIS)) + @mock.patch.object(cinder.Cinder, '_create_mountpoint') + @mock.patch.object(mount, 'do_mount') + def test_mount(self, mock_docker_volume, mock_create_mp, mock_do_mount): + fd, fake_devpath = tempfile.mkstemp() + fake_link_path = fake_devpath + fake_mountpoint = 'fake-mount-point/' + with mock.patch.object(FakeCinderConnector, 'get_device_path', + return_value=fake_link_path): + with mock.patch.object(cinder.Cinder, '_get_mountpoint', + return_value=fake_mountpoint): + self.assertEqual(fake_mountpoint, + self.cinderprovider.mount('fake-vol')) + + def test_unmount(self): + self.assertIsNone(self.cinderprovider.unmount('fake-vol')) + + def test_check_exists(self): + self.cinderprovider._get_docker_volume = mock.MagicMock() + self.cinderprovider._get_docker_volume.return_value = ( + None, + consts.UNKNOWN) + + result = self.cinderprovider.check_exist('fake-vol') + self.assertFalse(result) + + self.cinderprovider._get_docker_volume.return_value = ( + fake_object.FakeCinderVolume(), + consts.NOT_ATTACH) + + result = self.cinderprovider.check_exist('fake-vol') + self.assertTrue(result) diff --git a/fuxi/utils.py b/fuxi/utils.py new file mode 100644 index 0000000..e68c8ca --- /dev/null +++ b/fuxi/utils.py @@ -0,0 +1,187 @@ +# 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 flask +import os +import requests +import socket +import traceback + +from fuxi.common import constants +from fuxi import exceptions +from fuxi.i18n import _LW, _LE + +from cinderclient import client as cinder_client +from cinderclient import exceptions as cinder_exception +from keystoneauth1.session import Session +from keystoneclient.auth import get_plugin_class +from novaclient import client as nova_client +from novaclient import exceptions as nova_exception +from os_brick import exception as brick_exception +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import importutils +from oslo_utils import uuidutils +from werkzeug import exceptions as w_exceptions + +cloud_init_conf = '/var/lib/cloud/instances' + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + + +def get_hostname(): + return socket.gethostname() + + +def get_instance_uuid(): + try: + inst_uuid = '' + inst_uuid_count = 0 + dirs = os.listdir(cloud_init_conf) + for uuid_dir in dirs: + if uuidutils.is_uuid_like(uuid_dir): + inst_uuid = uuid_dir + inst_uuid_count += 1 + + # If not or not only get on instance_uuid, then search + # it from metadata server. + if inst_uuid_count == 1: + return inst_uuid + except Exception: + LOG.warning(_LW("Get instance_uuid from cloud-init failed")) + + try: + resp = requests.get('http://169.254.169.254/openstack', + timeout=constants.CURL_MD_TIMEOUT) + metadata_api_versions = resp.text.split() + metadata_api_versions.sort(reverse=True) + except Exception as e: + LOG.error(_LE("Get metadata apis failed. Error: {}").format(e)) + raise exceptions.FuxiException("Metadata API Not Found") + + for api_version in metadata_api_versions: + metadata_url = ''.join(['http://169.254.169.254/openstack/', + api_version, + '/meta_data.json']) + try: + resp = requests.get(metadata_url, + timeout=constants.CURL_MD_TIMEOUT) + metadata = resp.json() + if metadata.get('uuid', None): + return metadata['uuid'] + except Exception as e: + LOG.warning(_LW("Get instance_uuid from metadata server {0} " + "failed. Error: {1}").format(metadata_url, e)) + continue + + raise exceptions.FuxiException("Instance UUID Not Found") + + +# Return all errors as JSON. From http://flask.pocoo.org/snippets/83/ +def make_json_app(import_name, **kwargs): + app = flask.Flask(import_name, **kwargs) + + @app.errorhandler(exceptions.FuxiException) + @app.errorhandler(cinder_exception.ClientException) + @app.errorhandler(nova_exception.ClientException) + @app.errorhandler(processutils.ProcessExecutionError) + @app.errorhandler(brick_exception.BrickException) + def make_json_error(ex): + app.logger.error(_LE("Unexpected error happened: %s"), + traceback.format_exc()) + response = flask.jsonify({"Err": str(ex)}) + response.status_code = w_exceptions.InternalServerError.code + if isinstance(ex, w_exceptions.HTTPException): + response.status_code = ex.code + content_type = 'application/vnd.docker.plugins.v1+json; charset=utf-8' + response.headers['Content-Type'] = content_type + return response + + for code in w_exceptions.default_exceptions: + app.register_error_handler(code, make_json_error) + + return app + + +def driver_dict_from_config(named_driver_config, *args, **kwargs): + driver_registry = dict() + + for driver_str in named_driver_config: + driver_type, _sep, driver = driver_str.partition('=') + driver_class = importutils.import_class(driver) + driver_registry[driver_type] = driver_class(*args, **kwargs) + return driver_registry + + +def _openstack_auth_from_config(**config): + if config.get('username') and config.get('password'): + plugin_class = get_plugin_class('password') + else: + plugin_class = get_plugin_class('token') + plugin_options = plugin_class.get_options() + plugin_kwargs = {} + for option in plugin_options: + if option.dest in config: + plugin_kwargs[option.dest] = config[option.dest] + return plugin_class(**plugin_kwargs) + + +def get_keystone_session(**kwargs): + keystone_conf = CONF.keystone + config = {} + config['auth_url'] = keystone_conf.auth_url + config['username'] = keystone_conf.admin_user + config['password'] = keystone_conf.admin_password + config['tenant_name'] = keystone_conf.admin_tenant_name + config['token'] = keystone_conf.admin_token + config.update(kwargs) + + if keystone_conf.auth_insecure: + verify = False + else: + verify = keystone_conf.auth_ca_cert + + return Session(auth=_openstack_auth_from_config(**config), verify=verify) + + +def get_cinderclient(session=None, region=None, **kwargs): + if not session: + session = get_keystone_session(**kwargs) + if not region: + region = CONF.keystone['region'] + return cinder_client.Client(session=session, + region_name=region, + version=2) + + +def get_novaclient(session=None, region=None, **kwargs): + if not session: + session = get_keystone_session(**kwargs) + if not region: + region = CONF.keystone['region'] + return nova_client.Client(session=session, + region_name=region, + version=2) + + +def get_root_helper(): + return 'sudo fuxi-rootwrap %s' % CONF.rootwrap_config + + +def execute(*cmd, **kwargs): + if 'run_as_root' in kwargs and 'root_helper' not in kwargs: + kwargs['root_helper'] = get_root_helper() + + return processutils.execute(*cmd, **kwargs) diff --git a/fuxi/version.py b/fuxi/version.py new file mode 100644 index 0000000..41aa98f --- /dev/null +++ b/fuxi/version.py @@ -0,0 +1,17 @@ +# Copyright 2015 OpenStack Foundation +# +# 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 pbr.version + +version_info = pbr.version.VersionInfo('fuxi') diff --git a/fuxi/volumeprovider/__init__.py b/fuxi/volumeprovider/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuxi/volumeprovider/cinder.py b/fuxi/volumeprovider/cinder.py new file mode 100644 index 0000000..abaded0 --- /dev/null +++ b/fuxi/volumeprovider/cinder.py @@ -0,0 +1,433 @@ +# 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 os +import time + +from cinderclient import exceptions as cinder_exception +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import importutils +from oslo_utils import strutils + +from fuxi.common import constants as consts +from fuxi.common import mount +from fuxi.common import state_monitor +from fuxi import exceptions +from fuxi.i18n import _, _LE, _LI, _LW +from fuxi import utils +from fuxi.volumeprovider import provider + +CONF = cfg.CONF +cinder_conf = CONF.cinder + +# Volume states +UNKNOWN = consts.UNKNOWN +NOT_ATTACH = consts.NOT_ATTACH +ATTACH_TO_THIS = consts.ATTACH_TO_THIS +ATTACH_TO_OTHER = consts.ATTACH_TO_OTHER + +OPENSTACK = 'openstack' +OSBRICK = 'osbrick' + +volume_connector_conf = { + OPENSTACK: 'fuxi.connector.cloudconnector.openstack.CinderConnector', + OSBRICK: 'fuxi.connector.osbrickconnector.CinderConnector'} + +LOG = logging.getLogger(__name__) + + +def get_cinder_volume_kwargs(docker_volume_name, docker_volume_opt): + """Retrieve parameters for creating Cinder volume. + + Retrieve required parameters and remove unsupported arguments from + client input. These parameters are used to create a Cinder volume. + + :param docker_volume_name: Name for Cinder volume + :type docker_volume_name: str + :param docker_volume_opt: Optional parameters for Cinder volume + :type docker_volume_opt: dict + :rtype: dict + """ + options = ['size', 'consistencygroup_id', 'snapshot_id', 'source_volid', + 'description', 'volume_type', 'user_id', 'project_id', + 'availability_zone', 'scheduler_hints', 'source_replica', + 'multiattach'] + kwargs = {} + + if 'size' in docker_volume_opt: + try: + size = int(docker_volume_opt.pop('size')) + except ValueError: + msg = _LE("Volume size must be able to convert to int type") + LOG.error(msg) + raise exceptions.InvalidInput(msg) + else: + size = CONF.default_volume_size + msg = _LI("Volume size doesn't provide from command, so use " + "default size {0}G").format(size) + LOG.info(msg) + kwargs['size'] = size + + for key, value in docker_volume_opt.items(): + if key in options: + kwargs[key] = value + + if not kwargs.get('availability_zone', None): + kwargs['availability_zone'] = cinder_conf.availability_zone + + if not kwargs.get('volume_type', None): + kwargs['volume_type'] = cinder_conf.volume_type + + kwargs['name'] = docker_volume_name + kwargs['metadata'] = {consts.VOLUME_FROM: CONF.volume_from, + 'fstype': kwargs.pop('fstype', cinder_conf.fstype)} + + req_multiattach = kwargs.pop('multiattach', cinder_conf.multiattach) + kwargs['multiattach'] = strutils.bool_from_string(req_multiattach, + strict=True) + + return kwargs + + +def get_host_id(): + """Get a value that could represent this server.""" + host_id = None + volume_connector = cinder_conf.volume_connector + if volume_connector == OPENSTACK: + host_id = utils.get_instance_uuid() + elif volume_connector == OSBRICK: + host_id = utils.get_hostname().lower() + return host_id + + +class Cinder(provider.Provider): + volume_provider_type = 'cinder' + + def __init__(self): + super(Cinder, self).__init__() + self.cinderclient = utils.get_cinderclient() + + def _get_connector(self): + connector = cinder_conf.volume_connector + if not connector or connector not in volume_connector_conf: + msg = _LE("Must provide an valid volume connector") + LOG.error(msg) + raise exceptions.FuxiException(msg) + return importutils.import_class(volume_connector_conf[connector])() + + def _get_docker_volume(self, docker_volume_name): + LOG.info(_LI("Retrieve docker volume {0} from " + "Cinder").format(docker_volume_name)) + + try: + host_id = get_host_id() + + volume_connector = cinder_conf.volume_connector + search_opts = {'name': docker_volume_name, + 'metadata': {consts.VOLUME_FROM: CONF.volume_from}} + for vol in self.cinderclient.volumes.list(search_opts=search_opts): + if vol.name == docker_volume_name: + if vol.attachments: + for am in vol.attachments: + if volume_connector == OPENSTACK: + if am['server_id'] == host_id: + return vol, ATTACH_TO_THIS + elif volume_connector == OSBRICK: + if (am['host_name'] or '').lower() == host_id: + return vol, ATTACH_TO_THIS + return vol, ATTACH_TO_OTHER + else: + return vol, NOT_ATTACH + return None, UNKNOWN + except cinder_exception.ClientException as ex: + LOG.error(_LE("Error happened while getting volume list " + "information from cinder. Error: {0}").format(ex)) + raise + + def _check_attached_to_this(self, cinder_volume): + host_id = get_host_id() + vol_conn = cinder_conf.volume_connector + for am in cinder_volume.attachments: + if vol_conn == OPENSTACK and am['server_id'] == host_id: + return True + elif vol_conn == OSBRICK and am['host_name'] \ + and am['host_name'].lower() == host_id: + return True + return False + + def _create_volume(self, docker_volume_name, volume_opts): + LOG.info(_LI("Start to create docker volume {0} from " + "Cinder").format(docker_volume_name)) + + cinder_volume_kwargs = get_cinder_volume_kwargs(docker_volume_name, + volume_opts) + + try: + volume = self.cinderclient.volumes.create(**cinder_volume_kwargs) + except cinder_exception.ClientException as e: + msg = _LE("Error happened when create an volume {0} from Cinder. " + "Error: {1}").format(docker_volume_name, e) + LOG.error(msg) + raise + + LOG.info(_LI("Waiting volume {0} to be available").format(volume)) + volume_monitor = state_monitor.StateMonitor( + self.cinderclient, + volume, + 'available', + ('creating',), + time_delay=consts.VOLUME_SCAN_TIME_DELAY) + volume = volume_monitor.monitor_cinder_volume() + + LOG.info(_LI("Create docker volume {0} {1} from Cinder " + "successfully").format(docker_volume_name, volume)) + return volume + + def create(self, docker_volume_name, volume_opts): + if not volume_opts: + volume_opts = {} + + connector = self._get_connector() + cinder_volume, state = self._get_docker_volume(docker_volume_name) + LOG.info(_LI("Get docker volume {0} {1} with state " + "{2}").format(docker_volume_name, cinder_volume, state)) + + device_info = {} + if state == ATTACH_TO_THIS: + LOG.warn(_LW("The volume {0} {1} already exists and attached to " + "this server").format(docker_volume_name, + cinder_volume)) + device_info = {'path': connector.get_device_path(cinder_volume)} + elif state == NOT_ATTACH: + LOG.warn(_LW("The volume {0} {1} is already exists but not " + "attached").format(docker_volume_name, + cinder_volume)) + device_info = connector.connect_volume(cinder_volume) + elif state == ATTACH_TO_OTHER: + if cinder_volume.multiattach: + fstype = volume_opts.get('fstype', cinder_conf.fstype) + vol_fstype = cinder_volume.metadata.get('fstype', + cinder_conf.fstype) + if fstype != vol_fstype: + msg = _LE("Volume already exists with fstype: {0}, but " + "currently provided fstype is {1}, not " + "match").format(vol_fstype, fstype) + LOG.error(msg) + raise exceptions.FuxiException('FSType Not Match') + device_info = connector.connect_volume(cinder_volume) + else: + msg = _LE("The volume {0} {1} is already attached to another " + "server").format(docker_volume_name, cinder_volume) + LOG.error(msg) + raise exceptions.FuxiException(msg) + elif state == UNKNOWN: + volume_opts['name'] = docker_volume_name + cinder_volume = self._create_volume(docker_volume_name, + volume_opts) + device_info = connector.connect_volume(cinder_volume) + + return device_info + + def _delete_volume(self, volume): + try: + self.cinderclient.volumes.delete(volume) + except cinder_exception.NotFound: + return + except cinder_exception.ClientException as e: + msg = _LE("Error happened when delete volume from Cinder. " + "Error: {0}").format(e) + LOG.error(msg) + raise + + start_time = time.time() + # Wait until the volume is not there or until the operation timeout + while(time.time() - start_time < consts.DESTROY_VOLUME_TIMEOUT): + try: + self.cinderclient.volumes.get(volume.id) + except cinder_exception.NotFound: + return + time.sleep(consts.VOLUME_SCAN_TIME_DELAY) + + # If the volume is not deleted, raise an exception + msg_ft = _LE("Timed out while waiting for volume. " + "Expected Volume: {0}, " + "Expected State: {1}, " + "Elapsed Time: {2}").format(volume, + None, + time.time() - start_time) + raise exceptions.TimeoutException(msg_ft) + + def delete(self, docker_volume_name): + cinder_volume, state = self._get_docker_volume(docker_volume_name) + LOG.info(_LI("Get docker volume {0} {1} with state " + "{2}").format(docker_volume_name, cinder_volume, state)) + + if state == ATTACH_TO_THIS: + link_path = self._get_connector().get_device_path(cinder_volume) + if not link_path or not os.path.exists(link_path): + msg = _LE( + "Could not find device link path for volume {0} {1} " + "in host").format(docker_volume_name, cinder_volume) + LOG.error(msg) + raise exceptions.FuxiException(msg) + + devpath = os.path.realpath(link_path) + if not os.path.exists(devpath): + msg = _LE("Could not find device path for volume {0} {1} in " + "host").format(docker_volume_name, cinder_volume) + LOG.error(msg) + raise exceptions.FuxiException(msg) + + mounter = mount.Mounter() + mps = mounter.get_mps_by_device(devpath) + ref_count = len(mps) + if ref_count > 0: + mountpoint = self._get_mountpoint(docker_volume_name) + if mountpoint in mps: + mounter.unmount(mountpoint) + + self._clear_mountpoint(mountpoint) + + # If this volume is still mounted on other mount point, + # then return. + if ref_count > 1: + return True + else: + return True + + # Detach device from this server. + self._get_connector().disconnect_volume(cinder_volume) + + available_volume = self.cinderclient.volumes.get(cinder_volume.id) + # If this volume is not used by other server any more, + # than delete it from Cinder. + if not available_volume.attachments: + msg = _LW("No other servers still use this volume {0} " + "{1} any more, so delete it from Cinder" + "").format(docker_volume_name, cinder_volume) + LOG.warn(msg) + self._delete_volume(available_volume) + return True + elif state == UNKNOWN: + return False + else: + msg = _LE("The volume {0} {1} state must be {2} when " + "remove it from this server, but current state " + "is {3}").format(docker_volume_name, + cinder_volume, + ATTACH_TO_THIS, + state) + LOG.error(msg) + raise exceptions.NotMatchedState(msg) + + def list(self): + LOG.info(_LI("Start to retrieve all docker volumes from Cinder")) + + docker_volumes = [] + try: + search_opts = {'metadata': {consts.VOLUME_FROM: 'fuxi'}} + for vol in self.cinderclient.volumes.list(search_opts=search_opts): + docker_volume_name = vol.name + if not docker_volume_name or not vol.attachments: + continue + + mountpoint = self._get_mountpoint(vol.name) + if self._check_attached_to_this(vol): + devpath = os.path.realpath( + self._get_connector().get_device_path(vol)) + mps = mount.Mounter().get_mps_by_device(devpath) + mountpoint = mountpoint if mountpoint in mps else '' + docker_vol = {'Name': docker_volume_name, + 'Mountpoint': mountpoint} + docker_volumes.append(docker_vol) + except cinder_exception.ClientException as e: + LOG.error(_LE("Retrieve volume list failed. Error: {0}").format(e)) + raise + + LOG.info(_LI("Retrieve docker volumes {0} from Cinder " + "successfully").format(docker_volumes)) + return docker_volumes + + def show(self, docker_volume_name): + cinder_volume, state = self._get_docker_volume(docker_volume_name) + LOG.info(_LI("Get docker volume {0} {1} with state " + "{2}").format(docker_volume_name, cinder_volume, state)) + + if state == ATTACH_TO_THIS: + devpath = os.path.realpath( + self._get_connector().get_device_path(cinder_volume)) + mp = self._get_mountpoint(docker_volume_name) + LOG.info("Expected devpath: {0} and mountpoint: {1} for volume: " + "{2} {3}".format(devpath, mp, docker_volume_name, + cinder_volume)) + mounter = mount.Mounter() + return {"Name": docker_volume_name, + "Mountpoint": mp if mp in mounter.get_mps_by_device( + devpath) else ''} + elif state == UNKNOWN: + msg = _LW("Can't find this volume '{0}' in " + "Cinder").format(docker_volume_name) + LOG.warn(msg) + raise exceptions.NotFound(msg) + else: + msg = _LE("Volume '{0}' exists, but not attached to this volume," + "and current state is {1}").format(docker_volume_name, + state) + raise exceptions.NotMatchedState(msg) + + def mount(self, docker_volume_name): + cinder_volume, state = self._get_docker_volume(docker_volume_name) + LOG.info(_LI("Get docker volume {0} {1} with state " + "{2}").format(docker_volume_name, cinder_volume, state)) + + if state != ATTACH_TO_THIS: + msg = _("Volume {0} is not in correct state, current state " + "is {1}").format(docker_volume_name, state) + LOG.error(msg) + raise exceptions.FuxiException(msg) + + connector = self._get_connector() + + link_path = connector.get_device_path(cinder_volume) + if not os.path.exists(link_path): + LOG.warn(_LW("Could not find device link file, " + "so rebuild it")) + connector.disconnect_volume(cinder_volume) + connector.connect_volume(cinder_volume) + + devpath = os.path.realpath(link_path) + if not devpath or not os.path.exists(devpath): + msg = _("Can't find volume device path") + LOG.error(msg) + raise exceptions.FuxiException(msg) + + mountpoint = self._get_mountpoint(docker_volume_name) + self._create_mountpoint(mountpoint) + + fstype = cinder_volume.metadata.get('fstype', cinder_conf.fstype) + + mount.do_mount(devpath, mountpoint, fstype) + + return mountpoint + + def unmount(self, docker_volume_name): + return + + def check_exist(self, docker_volume_name): + _, state = self._get_docker_volume(docker_volume_name) + LOG.info(_LI("Get docker volume {0} with state " + "{1}").format(docker_volume_name, state)) + + if state == UNKNOWN: + return False + return True diff --git a/fuxi/volumeprovider/provider.py b/fuxi/volumeprovider/provider.py new file mode 100644 index 0000000..f503fbf --- /dev/null +++ b/fuxi/volumeprovider/provider.py @@ -0,0 +1,114 @@ +# 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 os +import six + +from fuxi import exceptions +from fuxi.i18n import _LI, _LE +from fuxi import utils + +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_log import log as logging + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + + +@six.add_metaclass(abc.ABCMeta) +class Provider(object): + """Base class for each volume provider. + + Provider provider some operation related with Docker volume provider by + each backend volume provider, like Cinder. + + """ + volume_provider_type = None + + def __init__(self): + pass + + @abc.abstractmethod + def create(self, docker_volume_name, volume_opts): + pass + + @abc.abstractmethod + def delete(self, docker_volume_name): + pass + + @abc.abstractmethod + def list(self): + pass + + @abc.abstractmethod + def show(self, docker_volume_name): + pass + + @abc.abstractmethod + def mount(self, docker_volume_name): + pass + + @abc.abstractmethod + def unmount(self, docker_volume_name): + pass + + @abc.abstractmethod + def check_exist(self, docker_volume_name): + pass + + def _get_mountpoint(self, docker_volume_name): + """Generate a mount point for volume. + + :param docker_volume_name: + :rtype: str + """ + if not docker_volume_name: + LOG.error(_LE("Volume name could not be None")) + raise exceptions.FuxiException("Volume name could not be None") + if self.volume_provider_type: + return os.path.join(CONF.volume_dir, + self.volume_provider_type, + docker_volume_name) + else: + return os.path.join(CONF.volume_dir, + docker_volume_name) + + def _create_mountpoint(self, mountpoint): + """Create mount point directory for Docker volume. + + :param mountpoint: The path of Docker volume. + """ + try: + if not os.path.exists(mountpoint) or not os.path.isdir(mountpoint): + utils.execute('mkdir', '-p', '-m=755', mountpoint, + run_as_root=True) + LOG.info(_LI("Create mountpoint %s successfully"), mountpoint) + except processutils.ProcessExecutionError as e: + LOG.error(_LE("Error happened when create volume " + "directory. Error: %s"), e) + raise + + def _clear_mountpoint(self, mountpoint): + """Clear mount point directory if it wouldn't used any more. + + :param mountpoint: The path of Docker volume. + """ + if os.path.exists(mountpoint) and os.path.isdir(mountpoint): + try: + utils.execute('rm', '-r', mountpoint, run_as_root=True) + LOG.info(_LI("Clear mountpoint %s successfully"), mountpoint) + except processutils.ProcessExecutionError as e: + LOG.error(_LE("Error happened when clear mountpoint {0}"), e) + raise diff --git a/requirements.txt b/requirements.txt index fb8b92b..bcb4b23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,20 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -pbr>=1.6 # Apache-2.0 +pbr>=1.6 # Apache-2.0 +pytz>=2013.6 # MIT +Babel>=2.3.4 # BSD +Flask>=0.10,!=0.11,<1.0 # BSD +keystoneauth1>=2.7.0 # Apache-2.0 +oslo.rootwrap>=2.0.0 # Apache-2.0 +oslo.concurrency>=3.8.0 # Apache-2.0 +oslo.config>=3.12.0 # Apache-2.0 +oslo.i18n>=2.1.0 # Apache-2.0 +oslo.log>=1.14.0 # Apache-2.0 +oslo.utils>=3.15.0 # Apache-2.0 +os-brick>=1.3.0,!=1.4.0 # Apache-2.0 +python-cinderclient>=1.6.0,!=1.7.0,!=1.7.1 # Apache-2.0 +python-novaclient>=2.29.0,!=2.33.0 # Apache-2.0 +python-keystoneclient>=1.7.0,!=1.8.0,!=2.1.0 # Apache-2.0 +requests>=2.10.0 # Apache-2.0 +six>=1.9.0 # MIT diff --git a/setup.cfg b/setup.cfg index 4689a4b..10c294a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,9 +19,25 @@ classifier = Programming Language :: Python :: 3.3 Programming Language :: Python :: 3.4 +[entry_points] +oslo.config.opts = + fuxi = fuxi.opts:list_fuxi_opts + +console_scripts = + fuxi-server = fuxi.server:start + fuxi-rootwrap = oslo_rootwrap.cmd:main + [files] packages = fuxi +data_files = + /etc/fuxi = + etc/fuxi.conf + etc/rootwrap.conf + /etc/fuxi/rootwrap.d = + etc/rootwrap.d/fuxi.filters + /usr/lib/docker/plugins/fuxi = + etc/fuxi.json [build_sphinx] source-dir = doc/source diff --git a/tox.ini b/tox.ini index e039486..f884335 100644 --- a/tox.ini +++ b/tox.ini @@ -1,60 +1,40 @@ [tox] minversion = 2.0 -envlist = py34-constraints,py27-constraints,pypy-constraints,pep8-constraints +envlist = py34,py27,pep8 skipsdist = True [testenv] usedevelop = True -install_command = - constraints: {[testenv:common-constraints]install_command} - pip install -U {opts} {packages} +install_command = pip install -U {opts} {packages} setenv = VIRTUAL_ENV={envdir} deps = -r{toxinidir}/test-requirements.txt commands = python setup.py testr --slowest --testr-args='{posargs}' -[testenv:common-constraints] -install_command = pip install -c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt} {opts} {packages} - [testenv:pep8] commands = flake8 {posargs} -[testenv:pep8-constraints] -install_command = {[testenv:common-constraints]install_command} -commands = flake8 {posargs} - [testenv:venv] commands = {posargs} -[testenv:venv-constraints] -install_command = {[testenv:common-constraints]install_command} -commands = {posargs} - [testenv:cover] commands = python setup.py test --coverage --testr-args='{posargs}' -[testenv:cover-constraints] -install_command = {[testenv:common-constraints]install_command} -commands = python setup.py test --coverage --testr-args='{posargs}' - [testenv:docs] commands = python setup.py build_sphinx -[testenv:docs-constraints] -install_command = {[testenv:common-constraints]install_command} -commands = python setup.py build_sphinx - [testenv:debug] commands = oslo_debug_helper {posargs} -[testenv:debug-constraints] -install_command = {[testenv:common-constraints]install_command} -commands = oslo_debug_helper {posargs} - [flake8] # E123, E125 skipped as they are invalid PEP-8. - show-source = True ignore = E123,E125 builtins = _ exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build + +[hacking] +import_exceptions = fuxi.i18n, fuxi.tests + +[testenv:genconfig] +commands = oslo-config-generator --config-file=etc/fuxi-config-generator.conf