From d8075263f52489d4f9550376eb5beba2f4c8285d Mon Sep 17 00:00:00 2001 From: Ben Swartzlander Date: Mon, 13 Aug 2012 16:57:50 -0400 Subject: [PATCH] Add driver for using files on a generic NFS server as virtual block devices Add NetApp-specific NFS virtual block driver blueprint nfs-files-as-virtual-block-devices blueprint netapp-nfs-cinder-driver Change-Id: I10ef6f3e230fcea2748152313d341db46bffce55 --- cinder/tests/test_netapp_nfs.py | 260 +++++++++++++ cinder/tests/test_nfs.py | 628 ++++++++++++++++++++++++++++++++ cinder/volume/netapp_nfs.py | 266 ++++++++++++++ cinder/volume/nfs.py | 309 ++++++++++++++++ 4 files changed, 1463 insertions(+) create mode 100644 cinder/tests/test_netapp_nfs.py create mode 100644 cinder/tests/test_nfs.py create mode 100644 cinder/volume/netapp_nfs.py create mode 100644 cinder/volume/nfs.py diff --git a/cinder/tests/test_netapp_nfs.py b/cinder/tests/test_netapp_nfs.py new file mode 100644 index 000000000..1ebd842cf --- /dev/null +++ b/cinder/tests/test_netapp_nfs.py @@ -0,0 +1,260 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2012 NetApp, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Unit tests for the NetApp-specific NFS driver module (netapp_nfs)""" + +from cinder import context +from cinder import test +from cinder import exception + +from cinder.volume import netapp_nfs +from cinder.volume import netapp +from cinder.volume import nfs +from mox import IsA +from mox import IgnoreArg +from mox import MockObject + +import mox +import suds +import types + + +class FakeVolume(object): + def __init__(self, size=0): + self.size = size + self.id = hash(self) + self.name = None + + def __getitem__(self, key): + return self.__dict__[key] + + +class FakeSnapshot(object): + def __init__(self, volume_size=0): + self.volume_name = None + self.name = None + self.volume_id = None + self.volume_size = volume_size + self.user_id = None + self.status = None + + def __getitem__(self, key): + return self.__dict__[key] + + +class FakeResponce(object): + def __init__(self, status): + """ + :param status: Either 'failed' or 'passed' + """ + self.Status = status + + if status == 'failed': + self.Reason = 'Sample error' + + +class NetappNfsDriverTestCase(test.TestCase): + """Test case for NetApp specific NFS clone driver""" + + def setUp(self): + self._driver = netapp_nfs.NetAppNFSDriver() + self._mox = mox.Mox() + + def tearDown(self): + self._mox.UnsetStubs() + + def test_check_for_setup_error(self): + mox = self._mox + drv = self._driver + required_flags = [ + 'netapp_wsdl_url', + 'netapp_login', + 'netapp_password', + 'netapp_server_hostname', + 'netapp_server_port' + ] + + # check exception raises when flags are not set + self.assertRaises(exception.CinderException, + drv.check_for_setup_error) + + # set required flags + for flag in required_flags: + setattr(netapp.FLAGS, flag, 'val') + + mox.StubOutWithMock(nfs.NfsDriver, 'check_for_setup_error') + nfs.NfsDriver.check_for_setup_error() + mox.ReplayAll() + + drv.check_for_setup_error() + + mox.VerifyAll() + + # restore initial FLAGS + for flag in required_flags: + delattr(netapp.FLAGS, flag) + + def test_do_setup(self): + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, 'check_for_setup_error') + mox.StubOutWithMock(netapp_nfs.NetAppNFSDriver, '_get_client') + + drv.check_for_setup_error() + netapp_nfs.NetAppNFSDriver._get_client() + + mox.ReplayAll() + + drv.do_setup(IsA(context.RequestContext)) + + mox.VerifyAll() + + def test_create_snapshot(self): + """Test snapshot can be created and deleted""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_clone_volume') + drv._clone_volume(IgnoreArg(), IgnoreArg(), IgnoreArg()) + mox.ReplayAll() + + drv.create_snapshot(FakeSnapshot()) + + mox.VerifyAll() + + def test_create_volume_from_snapshot(self): + """Tests volume creation from snapshot""" + drv = self._driver + mox = self._mox + volume = FakeVolume(1) + snapshot = FakeSnapshot(2) + + self.assertRaises(exception.CinderException, + drv.create_volume_from_snapshot, + volume, + snapshot) + + snapshot = FakeSnapshot(1) + + location = '127.0.0.1:/nfs' + expected_result = {'provider_location': location} + mox.StubOutWithMock(drv, '_clone_volume') + mox.StubOutWithMock(drv, '_get_volume_location') + drv._clone_volume(IgnoreArg(), IgnoreArg(), IgnoreArg()) + drv._get_volume_location(IgnoreArg()).AndReturn(location) + + mox.ReplayAll() + + loc = drv.create_volume_from_snapshot(volume, snapshot) + + self.assertEquals(loc, expected_result) + + mox.VerifyAll() + + def _prepare_delete_snapshot_mock(self, snapshot_exists): + drv = self._driver + mox = self._mox + + mox.StubOutWithMock(drv, '_get_provider_location') + mox.StubOutWithMock(drv, '_volume_not_present') + + if snapshot_exists: + mox.StubOutWithMock(drv, '_execute') + mox.StubOutWithMock(drv, '_get_volume_path') + + drv._get_provider_location(IgnoreArg()) + drv._volume_not_present(IgnoreArg(), IgnoreArg())\ + .AndReturn(not snapshot_exists) + + if snapshot_exists: + drv._get_volume_path(IgnoreArg(), IgnoreArg()) + drv._execute('rm', None, run_as_root=True) + + mox.ReplayAll() + + return mox + + def test_delete_existing_snapshot(self): + drv = self._driver + mox = self._prepare_delete_snapshot_mock(True) + + drv.delete_snapshot(FakeSnapshot()) + + mox.VerifyAll() + + def test_delete_missing_snapshot(self): + drv = self._driver + mox = self._prepare_delete_snapshot_mock(False) + + drv.delete_snapshot(FakeSnapshot()) + + mox.VerifyAll() + + def _prepare_clone_mock(self, status): + drv = self._driver + mox = self._mox + + volume = FakeVolume() + setattr(volume, 'provider_location', '127.0.0.1:/nfs') + + drv._client = MockObject(suds.client.Client) + drv._client.factory = MockObject(suds.client.Factory) + drv._client.service = MockObject(suds.client.ServiceSelector) + + # ApiProxy() method is generated by ServiceSelector at runtime from the + # XML, so mocking is impossible. + setattr(drv._client.service, + 'ApiProxy', + types.MethodType(lambda *args, **kwargs: FakeResponce(status), + suds.client.ServiceSelector)) + mox.StubOutWithMock(drv, '_get_host_id') + mox.StubOutWithMock(drv, '_get_full_export_path') + + drv._get_host_id(IgnoreArg()).AndReturn('10') + drv._get_full_export_path(IgnoreArg(), IgnoreArg()).AndReturn('/nfs') + + return mox + + def test_successfull_clone_volume(self): + drv = self._driver + mox = self._prepare_clone_mock('passed') + + mox.ReplayAll() + + volume_name = 'volume_name' + clone_name = 'clone_name' + volume_id = volume_name + str(hash(volume_name)) + + drv._clone_volume(volume_name, clone_name, volume_id) + + mox.VerifyAll() + + def test_failed_clone_volume(self): + drv = self._driver + mox = self._prepare_clone_mock('failed') + + mox.ReplayAll() + + volume_name = 'volume_name' + clone_name = 'clone_name' + volume_id = volume_name + str(hash(volume_name)) + + self.assertRaises(exception.CinderException, + drv._clone_volume, + volume_name, clone_name, volume_id) + + mox.VerifyAll() diff --git a/cinder/tests/test_nfs.py b/cinder/tests/test_nfs.py new file mode 100644 index 000000000..6bf6a0fce --- /dev/null +++ b/cinder/tests/test_nfs.py @@ -0,0 +1,628 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2012 NetApp, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Unit tests for the NFS driver module""" + +import os +import errno +import __builtin__ + +import mox as mox_lib +from mox import IsA +from mox import IgnoreArg +from mox import stubout + +from cinder import context +from cinder import test +from cinder.exception import ProcessExecutionError + +from cinder.volume import nfs + + +class DumbVolume(object): + fields = {} + + def __setitem__(self, key, value): + self.fields[key] = value + + def __getitem__(self, item): + return self.fields[item] + + +class NfsDriverTestCase(test.TestCase): + """Test case for NFS driver""" + + TEST_NFS_EXPORT1 = 'nfs-host1:/export' + TEST_NFS_EXPORT2 = 'nfs-host2:/export' + TEST_SIZE_IN_GB = 1 + TEST_MNT_POINT = '/mnt/nfs' + TEST_MNT_POINT_BASE = '/mnt/test' + TEST_LOCAL_PATH = '/mnt/nfs/volume-123' + TEST_FILE_NAME = 'test.txt' + TEST_SHARES_CONFIG_FILE = '/etc/cinder/test-shares.conf' + ONE_GB_IN_BYTES = 1024 * 1024 * 1024 + + def setUp(self): + self._driver = nfs.NfsDriver() + self._mox = mox_lib.Mox() + self.stubs = stubout.StubOutForTesting() + + def tearDown(self): + self._mox.UnsetStubs() + self.stubs.UnsetAll() + + def stub_out_not_replaying(self, obj, attr_name): + attr_to_replace = getattr(obj, attr_name) + stub = mox_lib.MockObject(attr_to_replace) + self.stubs.Set(obj, attr_name, stub) + + def test_path_exists_should_return_true(self): + """_path_exists should return True if stat returns 0""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_execute') + drv._execute('stat', self.TEST_FILE_NAME, run_as_root=True) + + mox.ReplayAll() + + self.assertTrue(drv._path_exists(self.TEST_FILE_NAME)) + + mox.VerifyAll() + + def test_path_exists_should_return_false(self): + """_path_exists should return True if stat doesn't return 0""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_execute') + drv._execute('stat', self.TEST_FILE_NAME, run_as_root=True).\ + AndRaise(ProcessExecutionError( + stderr="stat: cannot stat `test.txt': No such file or directory")) + + mox.ReplayAll() + + self.assertFalse(drv._path_exists(self.TEST_FILE_NAME)) + + mox.VerifyAll() + + def test_local_path(self): + """local_path common use case""" + nfs.FLAGS.nfs_mount_point_base = self.TEST_MNT_POINT_BASE + drv = self._driver + + volume = DumbVolume() + volume['provider_location'] = self.TEST_NFS_EXPORT1 + volume['name'] = 'volume-123' + + self.assertEqual('/mnt/test/12118957640568004265/volume-123', + drv.local_path(volume)) + + def test_mount_nfs_should_mount_correctly(self): + """_mount_nfs common case usage""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_path_exists') + drv._path_exists(self.TEST_MNT_POINT).AndReturn(True) + + mox.StubOutWithMock(drv, '_execute') + drv._execute('mount', '-t', 'nfs', self.TEST_NFS_EXPORT1, + self.TEST_MNT_POINT, run_as_root=True) + + mox.ReplayAll() + + drv._mount_nfs(self.TEST_NFS_EXPORT1, self.TEST_MNT_POINT) + + mox.VerifyAll() + + def test_mount_nfs_should_suppress_already_mounted_error(self): + """_mount_nfs should suppress already mounted error if ensure=True + """ + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_path_exists') + drv._path_exists(self.TEST_MNT_POINT).AndReturn(True) + + mox.StubOutWithMock(drv, '_execute') + drv._execute('mount', '-t', 'nfs', self.TEST_NFS_EXPORT1, + self.TEST_MNT_POINT, run_as_root=True).\ + AndRaise(ProcessExecutionError( + stderr='is busy or already mounted')) + + mox.ReplayAll() + + drv._mount_nfs(self.TEST_NFS_EXPORT1, self.TEST_MNT_POINT, ensure=True) + + mox.VerifyAll() + + def test_mount_nfs_should_reraise_already_mounted_error(self): + """_mount_nfs should not suppress already mounted error if ensure=False + """ + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_path_exists') + drv._path_exists(self.TEST_MNT_POINT).AndReturn(True) + + mox.StubOutWithMock(drv, '_execute') + drv._execute('mount', '-t', 'nfs', self.TEST_NFS_EXPORT1, + self.TEST_MNT_POINT, run_as_root=True).\ + AndRaise(ProcessExecutionError(stderr='is busy or already mounted')) + + mox.ReplayAll() + + self.assertRaises(ProcessExecutionError, drv._mount_nfs, + self.TEST_NFS_EXPORT1, self.TEST_MNT_POINT, + ensure=False) + + mox.VerifyAll() + + def test_mount_nfs_should_create_mountpoint_if_not_yet(self): + """_mount_nfs should create mountpoint if it doesn't exist""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_path_exists') + drv._path_exists(self.TEST_MNT_POINT).AndReturn(False) + + mox.StubOutWithMock(drv, '_execute') + drv._execute('mkdir', '-p', self.TEST_MNT_POINT) + drv._execute(*([IgnoreArg()] * 5), run_as_root=IgnoreArg()) + + mox.ReplayAll() + + drv._mount_nfs(self.TEST_NFS_EXPORT1, self.TEST_MNT_POINT) + + mox.VerifyAll() + + def test_mount_nfs_should_not_create_mountpoint_if_already(self): + """_mount_nfs should not create mountpoint if it already exists""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_path_exists') + drv._path_exists(self.TEST_MNT_POINT).AndReturn(True) + + mox.StubOutWithMock(drv, '_execute') + drv._execute(*([IgnoreArg()] * 5), run_as_root=IgnoreArg()) + + mox.ReplayAll() + + drv._mount_nfs(self.TEST_NFS_EXPORT1, self.TEST_MNT_POINT) + + mox.VerifyAll() + + def test_get_hash_str(self): + """_get_hash_str should calculation correct value""" + drv = self._driver + + self.assertEqual('12118957640568004265', + drv._get_hash_str(self.TEST_NFS_EXPORT1)) + + def test_get_mount_point_for_share(self): + """_get_mount_point_for_share should calculate correct value""" + drv = self._driver + + nfs.FLAGS.nfs_mount_point_base = self.TEST_MNT_POINT_BASE + + self.assertEqual('/mnt/test/12118957640568004265', + drv._get_mount_point_for_share(self.TEST_NFS_EXPORT1)) + + def test_get_available_capacity_with_df(self): + """_get_available_capacity should calculate correct value""" + mox = self._mox + drv = self._driver + + df_avail = 1490560 + df_head = 'Filesystem 1K-blocks Used Available Use% Mounted on\n' + df_data = 'nfs-host:/export 2620544 996864 %d 41%% /mnt' % df_avail + df_output = df_head + df_data + + setattr(nfs.FLAGS, 'nfs_disk_util', 'df') + + mox.StubOutWithMock(drv, '_get_mount_point_for_share') + drv._get_mount_point_for_share(self.TEST_NFS_EXPORT1).\ + AndReturn(self.TEST_MNT_POINT) + + mox.StubOutWithMock(drv, '_execute') + drv._execute('df', '-P', '-B', '1', self.TEST_MNT_POINT, + run_as_root=True).AndReturn((df_output, None)) + + mox.ReplayAll() + + self.assertEquals(df_avail, + drv._get_available_capacity(self.TEST_NFS_EXPORT1)) + + mox.VerifyAll() + + delattr(nfs.FLAGS, 'nfs_disk_util') + + def test_get_available_capacity_with_du(self): + """_get_available_capacity should calculate correct value""" + mox = self._mox + drv = self._driver + + setattr(nfs.FLAGS, 'nfs_disk_util', 'du') + + df_total_size = 2620544 + df_used_size = 996864 + df_avail_size = 1490560 + df_title = 'Filesystem 1-blocks Used Available Use% Mounted on\n' + df_mnt_data = 'nfs-host:/export %d %d %d 41%% /mnt' % (df_total_size, + df_used_size, + df_avail_size) + df_output = df_title + df_mnt_data + + du_used = 490560 + du_output = '%d /mnt' % du_used + + mox.StubOutWithMock(drv, '_get_mount_point_for_share') + drv._get_mount_point_for_share(self.TEST_NFS_EXPORT1).\ + AndReturn(self.TEST_MNT_POINT) + + mox.StubOutWithMock(drv, '_execute') + drv._execute('df', '-P', '-B', '1', self.TEST_MNT_POINT, + run_as_root=True).\ + AndReturn((df_output, None)) + drv._execute('du', '-sb', '--apparent-size', + '--exclude', '*snapshot*', + self.TEST_MNT_POINT, + run_as_root=True).AndReturn((du_output, None)) + + mox.ReplayAll() + + self.assertEquals(df_total_size - du_used, + drv._get_available_capacity(self.TEST_NFS_EXPORT1)) + + mox.VerifyAll() + + delattr(nfs.FLAGS, 'nfs_disk_util') + + def test_load_shares_config(self): + mox = self._mox + drv = self._driver + + nfs.FLAGS.nfs_shares_config = self.TEST_SHARES_CONFIG_FILE + + mox.StubOutWithMock(__builtin__, 'open') + config_data = [] + config_data.append(self.TEST_NFS_EXPORT1) + config_data.append('#' + self.TEST_NFS_EXPORT2) + config_data.append('') + __builtin__.open(self.TEST_SHARES_CONFIG_FILE).AndReturn(config_data) + mox.ReplayAll() + + shares = drv._load_shares_config() + + self.assertEqual([self.TEST_NFS_EXPORT1], shares) + + mox.VerifyAll() + + def test_ensure_share_mounted(self): + """_ensure_share_mounted simple use case""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_get_mount_point_for_share') + drv._get_mount_point_for_share(self.TEST_NFS_EXPORT1).\ + AndReturn(self.TEST_MNT_POINT) + + mox.StubOutWithMock(drv, '_mount_nfs') + drv._mount_nfs(self.TEST_NFS_EXPORT1, self.TEST_MNT_POINT, ensure=True) + + mox.ReplayAll() + + drv._ensure_share_mounted(self.TEST_NFS_EXPORT1) + + mox.VerifyAll() + + def test_ensure_shares_mounted_should_save_mounting_successfully(self): + """_ensure_shares_mounted should save share if mounted with success""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_load_shares_config') + drv._load_shares_config().AndReturn([self.TEST_NFS_EXPORT1]) + mox.StubOutWithMock(drv, '_ensure_share_mounted') + drv._ensure_share_mounted(self.TEST_NFS_EXPORT1) + + mox.ReplayAll() + + drv._ensure_shares_mounted() + + self.assertEqual(1, len(drv._mounted_shares)) + self.assertEqual(self.TEST_NFS_EXPORT1, drv._mounted_shares[0]) + + mox.VerifyAll() + + def test_ensure_shares_mounted_should_not_save_mounting_with_error(self): + """_ensure_shares_mounted should not save share if failed to mount""" + mox = self._mox + drv = self._driver + + mox.StubOutWithMock(drv, '_load_shares_config') + drv._load_shares_config().AndReturn([self.TEST_NFS_EXPORT1]) + mox.StubOutWithMock(drv, '_ensure_share_mounted') + drv._ensure_share_mounted(self.TEST_NFS_EXPORT1).AndRaise(Exception()) + + mox.ReplayAll() + + drv._ensure_shares_mounted() + + self.assertEqual(0, len(drv._mounted_shares)) + + mox.VerifyAll() + + def test_setup_should_throw_error_if_shares_config_not_configured(self): + """do_setup should throw error if shares config is not configured """ + drv = self._driver + + nfs.FLAGS.nfs_shares_config = self.TEST_SHARES_CONFIG_FILE + + self.assertRaises(nfs.NfsException, + drv.do_setup, IsA(context.RequestContext)) + + def test_setup_should_throw_exception_if_nfs_client_is_not_installed(self): + """do_setup should throw error if nfs client is not installed """ + mox = self._mox + drv = self._driver + + nfs.FLAGS.nfs_shares_config = self.TEST_SHARES_CONFIG_FILE + + mox.StubOutWithMock(os.path, 'exists') + os.path.exists(self.TEST_SHARES_CONFIG_FILE).AndReturn(True) + mox.StubOutWithMock(drv, '_execute') + drv._execute('mount.nfs', check_exit_code=False).\ + AndRaise(OSError(errno.ENOENT, 'No such file or directory')) + + mox.ReplayAll() + + self.assertRaises(nfs.NfsException, + drv.do_setup, IsA(context.RequestContext)) + + mox.VerifyAll() + + def test_find_share_should_throw_error_if_there_is_no_mounted_shares(self): + """_find_share should throw error if there is no mounted shares""" + drv = self._driver + + drv._mounted_shares = [] + + self.assertRaises(nfs.NfsException, drv._find_share, + self.TEST_SIZE_IN_GB) + + def test_find_share(self): + """_find_share simple use case""" + mox = self._mox + drv = self._driver + + drv._mounted_shares = [self.TEST_NFS_EXPORT1, self.TEST_NFS_EXPORT2] + + mox.StubOutWithMock(drv, '_get_available_capacity') + drv._get_available_capacity(self.TEST_NFS_EXPORT1).\ + AndReturn(2 * self.ONE_GB_IN_BYTES) + drv._get_available_capacity(self.TEST_NFS_EXPORT2).\ + AndReturn(3 * self.ONE_GB_IN_BYTES) + + mox.ReplayAll() + + self.assertEqual(self.TEST_NFS_EXPORT2, + drv._find_share(self.TEST_SIZE_IN_GB)) + + mox.VerifyAll() + + def test_find_share_should_throw_error_if_there_is_no_enough_place(self): + """_find_share should throw error if there is no share to host vol""" + mox = self._mox + drv = self._driver + + drv._mounted_shares = [self.TEST_NFS_EXPORT1, self.TEST_NFS_EXPORT2] + + mox.StubOutWithMock(drv, '_get_available_capacity') + drv._get_available_capacity(self.TEST_NFS_EXPORT1).\ + AndReturn(0) + drv._get_available_capacity(self.TEST_NFS_EXPORT2).\ + AndReturn(0) + + mox.ReplayAll() + + self.assertRaises(nfs.NfsNoSuitableShareFound, drv._find_share, + self.TEST_SIZE_IN_GB) + + mox.VerifyAll() + + def _simple_volume(self): + volume = DumbVolume() + volume['provider_location'] = '127.0.0.1:/mnt' + volume['name'] = 'volume_name' + volume['size'] = 10 + + return volume + + def test_create_sparsed_volume(self): + mox = self._mox + drv = self._driver + volume = self._simple_volume() + + setattr(nfs.FLAGS, 'nfs_sparsed_volumes', True) + + mox.StubOutWithMock(drv, '_create_sparsed_file') + mox.StubOutWithMock(drv, '_set_rw_permissions_for_all') + + drv._create_sparsed_file(IgnoreArg(), IgnoreArg()) + drv._set_rw_permissions_for_all(IgnoreArg()) + + mox.ReplayAll() + + drv._do_create_volume(volume) + + mox.VerifyAll() + + delattr(nfs.FLAGS, 'nfs_sparsed_volumes') + + def test_create_nonsparsed_volume(self): + mox = self._mox + drv = self._driver + volume = self._simple_volume() + + setattr(nfs.FLAGS, 'nfs_sparsed_volumes', False) + + mox.StubOutWithMock(drv, '_create_regular_file') + mox.StubOutWithMock(drv, '_set_rw_permissions_for_all') + + drv._create_regular_file(IgnoreArg(), IgnoreArg()) + drv._set_rw_permissions_for_all(IgnoreArg()) + + mox.ReplayAll() + + drv._do_create_volume(volume) + + mox.VerifyAll() + + delattr(nfs.FLAGS, 'nfs_sparsed_volumes') + + def test_create_volume_should_ensure_nfs_mounted(self): + """create_volume should ensure shares provided in config are mounted""" + mox = self._mox + drv = self._driver + + self.stub_out_not_replaying(nfs, 'LOG') + self.stub_out_not_replaying(drv, '_find_share') + self.stub_out_not_replaying(drv, '_do_create_volume') + + mox.StubOutWithMock(drv, '_ensure_shares_mounted') + drv._ensure_shares_mounted() + + mox.ReplayAll() + + volume = DumbVolume() + volume['size'] = self.TEST_SIZE_IN_GB + drv.create_volume(volume) + + mox.VerifyAll() + + def test_create_volume_should_return_provider_location(self): + """create_volume should return provider_location with found share """ + mox = self._mox + drv = self._driver + + self.stub_out_not_replaying(nfs, 'LOG') + self.stub_out_not_replaying(drv, '_ensure_shares_mounted') + self.stub_out_not_replaying(drv, '_do_create_volume') + + mox.StubOutWithMock(drv, '_find_share') + drv._find_share(self.TEST_SIZE_IN_GB).AndReturn(self.TEST_NFS_EXPORT1) + + mox.ReplayAll() + + volume = DumbVolume() + volume['size'] = self.TEST_SIZE_IN_GB + result = drv.create_volume(volume) + self.assertEqual(self.TEST_NFS_EXPORT1, result['provider_location']) + + mox.VerifyAll() + + def test_delete_volume(self): + """delete_volume simple test case""" + mox = self._mox + drv = self._driver + + self.stub_out_not_replaying(drv, '_ensure_share_mounted') + + volume = DumbVolume() + volume['name'] = 'volume-123' + volume['provider_location'] = self.TEST_NFS_EXPORT1 + + mox.StubOutWithMock(drv, 'local_path') + drv.local_path(volume).AndReturn(self.TEST_LOCAL_PATH) + + mox.StubOutWithMock(drv, '_path_exists') + drv._path_exists(self.TEST_LOCAL_PATH).AndReturn(True) + + mox.StubOutWithMock(drv, '_execute') + drv._execute('rm', '-f', self.TEST_LOCAL_PATH, run_as_root=True) + + mox.ReplayAll() + + drv.delete_volume(volume) + + mox.VerifyAll() + + def test_delete_should_ensure_share_mounted(self): + """delete_volume should ensure that corresponding share is mounted""" + mox = self._mox + drv = self._driver + + self.stub_out_not_replaying(drv, '_execute') + + volume = DumbVolume() + volume['name'] = 'volume-123' + volume['provider_location'] = self.TEST_NFS_EXPORT1 + + mox.StubOutWithMock(drv, '_ensure_share_mounted') + drv._ensure_share_mounted(self.TEST_NFS_EXPORT1) + + mox.ReplayAll() + + drv.delete_volume(volume) + + mox.VerifyAll() + + def test_delete_should_not_delete_if_provider_location_not_provided(self): + """delete_volume shouldn't try to delete if provider_location missed""" + mox = self._mox + drv = self._driver + + self.stub_out_not_replaying(drv, '_ensure_share_mounted') + + volume = DumbVolume() + volume['name'] = 'volume-123' + volume['provider_location'] = None + + mox.StubOutWithMock(drv, '_execute') + + mox.ReplayAll() + + drv.delete_volume(volume) + + mox.VerifyAll() + + def test_delete_should_not_delete_if_there_is_no_file(self): + """delete_volume should not try to delete if file missed""" + mox = self._mox + drv = self._driver + + self.stub_out_not_replaying(drv, '_ensure_share_mounted') + + volume = DumbVolume() + volume['name'] = 'volume-123' + volume['provider_location'] = self.TEST_NFS_EXPORT1 + + mox.StubOutWithMock(drv, 'local_path') + drv.local_path(volume).AndReturn(self.TEST_LOCAL_PATH) + + mox.StubOutWithMock(drv, '_path_exists') + drv._path_exists(self.TEST_LOCAL_PATH).AndReturn(False) + + mox.StubOutWithMock(drv, '_execute') + + mox.ReplayAll() + + drv.delete_volume(volume) + + mox.VerifyAll() diff --git a/cinder/volume/netapp_nfs.py b/cinder/volume/netapp_nfs.py new file mode 100644 index 000000000..dd69c6dec --- /dev/null +++ b/cinder/volume/netapp_nfs.py @@ -0,0 +1,266 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2012 NetApp, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +Volume driver for NetApp NFS storage. +""" + +import os +import time +import suds +from suds.sax import text + +from cinder import exception +from cinder import flags +from cinder.openstack.common import cfg +from cinder.openstack.common import log as logging +from cinder.volume import nfs +from cinder.volume.netapp import netapp_opts + +LOG = logging.getLogger("cinder.volume.driver") + +netapp_nfs_opts = [ + cfg.IntOpt('synchronous_snapshot_create', + default=0, + help='Does snapshot creation call returns immediately') + ] + +FLAGS = flags.FLAGS +FLAGS.register_opts(netapp_opts) +FLAGS.register_opts(netapp_nfs_opts) + + +class NetAppNFSDriver(nfs.NfsDriver): + """Executes commands relating to Volumes.""" + def __init__(self, *args, **kwargs): + # NOTE(vish): db is set by Manager + self._execute = None + self._context = None + super(NetAppNFSDriver, self).__init__(*args, **kwargs) + + def set_execute(self, execute): + self._execute = execute + + def do_setup(self, context): + self._context = context + self.check_for_setup_error() + self._client = NetAppNFSDriver._get_client() + + def check_for_setup_error(self): + """Returns an error if prerequisites aren't met""" + NetAppNFSDriver._check_dfm_flags() + super(NetAppNFSDriver, self).check_for_setup_error() + + def create_volume_from_snapshot(self, volume, snapshot): + """Creates a volume from a snapshot.""" + vol_size = volume.size + snap_size = snapshot.volume_size + + if vol_size != snap_size: + msg = _('Cannot create volume of size %(vol_size)s from ' + 'snapshot of size %(snap_size)s') + raise exception.CinderException(msg % locals()) + + self._clone_volume(snapshot.name, volume.name, snapshot.volume_id) + share = self._get_volume_location(snapshot.volume_id) + + return {'provider_location': share} + + def create_snapshot(self, snapshot): + """Creates a snapshot.""" + self._clone_volume(snapshot['volume_name'], + snapshot['name'], + snapshot['volume_id']) + + def delete_snapshot(self, snapshot): + """Deletes a snapshot.""" + nfs_mount = self._get_provider_location(snapshot.volume_id) + + if self._volume_not_present(nfs_mount, snapshot.name): + return True + + self._execute('rm', self._get_volume_path(nfs_mount, snapshot.name), + run_as_root=True) + + @staticmethod + def _check_dfm_flags(): + """Raises error if any required configuration flag for OnCommand proxy + is missing.""" + required_flags = ['netapp_wsdl_url', + 'netapp_login', + 'netapp_password', + 'netapp_server_hostname', + 'netapp_server_port'] + for flag in required_flags: + if not getattr(FLAGS, flag, None): + raise exception.CinderException(_('%s is not set') % flag) + + @staticmethod + def _get_client(): + """Creates SOAP _client for ONTAP-7 DataFabric Service.""" + client = suds.client.Client(FLAGS.netapp_wsdl_url, + username=FLAGS.netapp_login, + password=FLAGS.netapp_password) + soap_url = 'http://%s:%s/apis/soap/v1' % ( + FLAGS.netapp_server_hostname, + FLAGS.netapp_server_port) + client.set_options(location=soap_url) + + return client + + def _get_volume_location(self, volume_id): + """Returns NFS mount address as :""" + nfs_server_ip = self._get_host_ip(volume_id) + export_path = self._get_export_path(volume_id) + return (nfs_server_ip + ':' + export_path) + + def _clone_volume(self, volume_name, clone_name, volume_id): + """Clones mounted volume with OnCommand proxy API""" + host_id = self._get_host_id(volume_id) + export_path = self._get_full_export_path(volume_id, host_id) + + request = self._client.factory.create('Request') + request.Name = 'clone-start' + + clone_start_args = ('%s/%s' + '%s/%s') + + request.Args = text.Raw(clone_start_args % (export_path, + volume_name, + export_path, + clone_name)) + + resp = self._client.service.ApiProxy(Target=host_id, + Request=request) + + if resp.Status == 'passed' and FLAGS.synchronous_snapshot_create: + clone_id = resp.Results['clone-id'][0] + clone_id_info = clone_id['clone-id-info'][0] + clone_operation_id = int(clone_id_info['clone-op-id'][0]) + + self._wait_for_clone_finished(clone_operation_id, host_id) + elif resp.Status == 'failed': + raise exception.CinderException(resp.Reason) + + def _wait_for_clone_finished(self, clone_operation_id, host_id): + """ + Polls ONTAP7 for clone status. Returns once clone is finished. + :param clone_operation_id: Identifier of ONTAP clone operation + """ + clone_list_options = ('' + '' + '%d' + '' + '' + '') + + request = self._client.factory.create('Request') + request.Name = 'clone-list-status' + request.Args = text.Raw(clone_list_options % clone_operation_id) + + resp = self._client.service.ApiProxy(Target=host_id, Request=request) + + while resp.Status != 'passed': + time.sleep(1) + resp = self._client.service.ApiProxy(Target=host_id, + Request=request) + + def _get_provider_location(self, volume_id): + """ + Returns provider location for given volume + :param volume_id: + """ + volume = self.db.volume_get(self._context, volume_id) + return volume.provider_location + + def _get_host_ip(self, volume_id): + """Returns IP address for the given volume""" + return self._get_provider_location(volume_id).split(':')[0] + + def _get_export_path(self, volume_id): + """Returns NFS export path for the given volume""" + return self._get_provider_location(volume_id).split(':')[1] + + def _get_host_id(self, volume_id): + """Returns ID of the ONTAP-7 host""" + host_ip = self._get_host_ip(volume_id) + server = self._client.service + + resp = server.HostListInfoIterStart(ObjectNameOrId=host_ip) + tag = resp.Tag + + try: + res = server.HostListInfoIterNext(Tag=tag, Maximum=1) + if hasattr(res, 'Hosts') and res.Hosts.HostInfo: + return res.Hosts.HostInfo[0].HostId + finally: + server.HostListInfoIterEnd(Tag=tag) + + def _get_full_export_path(self, volume_id, host_id): + """Returns full path to the NFS share, e.g. /vol/vol0/home""" + export_path = self._get_export_path(volume_id) + command_args = '%s' + + request = self._client.factory.create('Request') + request.Name = 'nfs-exportfs-storage-path' + request.Args = text.Raw(command_args % export_path) + + resp = self._client.service.ApiProxy(Target=host_id, + Request=request) + + if resp.Status == 'passed': + return resp.Results['actual-pathname'][0] + elif resp.Status == 'failed': + raise exception.CinderException(resp.Reason) + + def _volume_not_present(self, nfs_mount, volume_name): + """ + Check if volume exists + """ + try: + self._try_execute('ls', self._get_volume_path(nfs_mount, + volume_name)) + except exception.ProcessExecutionError: + # If the volume isn't present + return True + return False + + def _try_execute(self, *command, **kwargs): + # NOTE(vish): Volume commands can partially fail due to timing, but + # running them a second time on failure will usually + # recover nicely. + tries = 0 + while True: + try: + self._execute(*command, **kwargs) + return True + except exception.ProcessExecutionError: + tries = tries + 1 + if tries >= FLAGS.num_shell_tries: + raise + LOG.exception(_("Recovering from a failed execute. " + "Try number %s"), tries) + time.sleep(tries ** 2) + + def _get_volume_path(self, nfs_share, volume_name): + """Get volume path (local fs path) for given volume name on given nfs + share + @param nfs_share string, example 172.18.194.100:/var/nfs + @param volume_name string, + example volume-91ee65ec-c473-4391-8c09-162b00c68a8c + """ + return os.path.join(self._get_mount_point_for_share(nfs_share), + volume_name) diff --git a/cinder/volume/nfs.py b/cinder/volume/nfs.py new file mode 100644 index 000000000..5ea23674c --- /dev/null +++ b/cinder/volume/nfs.py @@ -0,0 +1,309 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2012 NetApp, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import errno +import ctypes + +from cinder import flags +from cinder.openstack.common import cfg +from cinder.openstack.common import log as logging +from cinder.volume import driver +from cinder import exception + +LOG = logging.getLogger("cinder.volume.driver") + +volume_opts = [ + cfg.StrOpt('nfs_shares_config', + default=None, + help='File with the list of available nfs shares'), + cfg.StrOpt('nfs_mount_point_base', + default='$state_path/mnt', + help='Base dir where nfs expected to be mounted'), + cfg.StrOpt('nfs_disk_util', + default='df', + help='Use du or df for free space calculation'), + cfg.BoolOpt('nfs_sparsed_volumes', + default=True, + help=('Create volumes as sparsed files which take no space.' + 'If set to False volume is created as regular file.' + 'In such case volume creation takes a lot of time.')) +] + +FLAGS = flags.FLAGS +FLAGS.register_opts(volume_opts) + + +class NfsException(exception.CinderException): + pass + + +class NfsNoSharesMounted(NfsException): + pass + + +class NfsNoSuitableShareFound(NfsException): + pass + + +class NfsDriver(driver.VolumeDriver): + """NFS based cinder driver. Creates file on NFS share for using it + as block device on hypervisor.""" + + def do_setup(self, context): + """Any initialization the volume driver does while starting""" + super(NfsDriver, self).do_setup(context) + + config = FLAGS.nfs_shares_config + if not config: + LOG.warn(_("There's no NFS config file configured ")) + if not config or not os.path.exists(config): + msg = _("NFS config file doesn't exist") + LOG.warn(msg) + raise NfsException(msg) + + try: + self._execute('mount.nfs', check_exit_code=False) + except OSError as exc: + if exc.errno == errno.ENOENT: + raise NfsException('mount.nfs is not installed') + else: + raise + + def check_for_setup_error(self): + """Just to override parent behavior""" + pass + + def create_volume(self, volume): + """Creates a volume""" + + self._ensure_shares_mounted() + + volume['provider_location'] = self._find_share(volume['size']) + + LOG.info(_('casted to %s') % volume['provider_location']) + + self._do_create_volume(volume) + + return {'provider_location': volume['provider_location']} + + def delete_volume(self, volume): + """Deletes a logical volume.""" + + if not volume['provider_location']: + LOG.warn(_('Volume %s does not have provider_location specified, ' + 'skipping'), volume['name']) + return True + + self._ensure_share_mounted(volume['provider_location']) + + mounted_path = self.local_path(volume) + + if not self._path_exists(mounted_path): + volume = volume['name'] + + LOG.warn(_('Trying to delete non-existing volume %(volume)s at ' + 'path %(mounted_path)s') % locals()) + return True + +# self._execute('dd', 'if=/dev/zero', 'of=%s' % mounted_volume_path, +# 'bs=1M', run_as_root=True) + self._execute('rm', '-f', mounted_path, run_as_root=True) + + def ensure_export(self, ctx, volume): + """Synchronously recreates an export for a logical volume.""" + self._ensure_share_mounted(volume['provider_location']) + + def create_export(self, ctx, volume): + """Exports the volume. Can optionally return a Dictionary of changes + to the volume object to be persisted.""" + pass + + def remove_export(self, ctx, volume): + """Removes an export for a logical volume.""" + pass + + def check_for_export(self, context, volume_id): + """Make sure volume is exported.""" + pass + + def initialize_connection(self, volume, connector): + """Allow connection to connector and return connection info.""" + data = {'export': volume['provider_location'], + 'name': volume['name']} + return { + 'driver_volume_type': 'nfs', + 'data': data + } + + def terminate_connection(self, volume, connector): + """Disallow connection from connector""" + pass + + def local_path(self, volume): + """Get volume path (mounted locally fs path) for given volume + :param volume: volume reference + """ + nfs_share = volume['provider_location'] + return os.path.join(self._get_mount_point_for_share(nfs_share), + volume['name']) + + def _create_sparsed_file(self, path, size): + """Creates file with 0 disk usage""" + self._execute('truncate', '-s', self._sizestr(size), + path, run_as_root=True) + + def _create_regular_file(self, path, size): + """Creates regular file of given size. Takes a lot of time for large + files""" + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + + block_size_mb = 1 + block_count = size * GB / (block_size_mb * MB) + + self._execute('dd', 'if=/dev/zero', 'of=%s' % path, + 'bs=%dM' % block_size_mb, + 'count=%d' % block_count, + run_as_root=True) + + def _set_rw_permissions_for_all(self, path): + """Sets 666 permissions for the path""" + self._execute('chmod', 'ugo+rw', path, run_as_root=True) + + def _do_create_volume(self, volume): + """Create a volume on given nfs_share + :param volume: volume reference + """ + volume_path = self.local_path(volume) + volume_size = volume['size'] + + if FLAGS.nfs_sparsed_volumes: + self._create_sparsed_file(volume_path, volume_size) + else: + self._create_regular_file(volume_path, volume_size) + + self._set_rw_permissions_for_all(volume_path) + + def _ensure_shares_mounted(self): + """Look for NFS shares in the flags and tries to mount them locally""" + self._mounted_shares = [] + + for share in self._load_shares_config(): + try: + self._ensure_share_mounted(share) + self._mounted_shares.append(share) + except Exception, exc: + LOG.warning('Exception during mounting %s' % (exc,)) + + LOG.debug('Available shares %s' % str(self._mounted_shares)) + + def _load_shares_config(self): + return [share.strip() for share in open(FLAGS.nfs_shares_config) + if share and not share.startswith('#')] + + def _ensure_share_mounted(self, nfs_share): + """Mount NFS share + :param nfs_share: + """ + mount_path = self._get_mount_point_for_share(nfs_share) + self._mount_nfs(nfs_share, mount_path, ensure=True) + + def _find_share(self, volume_size_for): + """Choose NFS share among available ones for given volume size. Current + implementation looks for greatest capacity + :param volume_size_for: int size in Gb + """ + + if not self._mounted_shares: + raise NfsNoSharesMounted( + _("There is no any mounted NFS share found")) + + greatest_size = 0 + greatest_share = None + + for nfs_share in self._mounted_shares: + capacity = self._get_available_capacity(nfs_share) + if capacity > greatest_size: + greatest_share = nfs_share + greatest_size = capacity + + if volume_size_for * 1024 * 1024 * 1024 > greatest_size: + raise NfsNoSuitableShareFound( + _('There is no share which can host %sG') % volume_size_for) + return greatest_share + + def _get_mount_point_for_share(self, nfs_share): + """ + :param nfs_share: example 172.18.194.100:/var/nfs + """ + return os.path.join(FLAGS.nfs_mount_point_base, + self._get_hash_str(nfs_share)) + + def _get_available_capacity(self, nfs_share): + """Calculate available space on the NFS share + :param nfs_share: example 172.18.194.100:/var/nfs + """ + mount_point = self._get_mount_point_for_share(nfs_share) + + out, _ = self._execute('df', '-P', '-B', '1', mount_point, + run_as_root=True) + out = out.splitlines()[1] + + available = 0 + + if FLAGS.nfs_disk_util == 'df': + available = int(out.split()[3]) + else: + size = int(out.split()[1]) + out, _ = self._execute('du', '-sb', '--apparent-size', + '--exclude', '*snapshot*', mount_point, + run_as_root=True) + used = int(out.split()[0]) + available = size - used + + return available + + def _mount_nfs(self, nfs_share, mount_path, ensure=False): + """Mount NFS share to mount path""" + if not self._path_exists(mount_path): + self._execute('mkdir', '-p', mount_path) + + try: + self._execute('mount', '-t', 'nfs', nfs_share, mount_path, + run_as_root=True) + except exception.ProcessExecutionError as exc: + if ensure and 'already mounted' in exc.stderr: + LOG.warn(_("%s is already mounted"), nfs_share) + else: + raise + + def _path_exists(self, path): + """Check given path """ + try: + self._execute('stat', path, run_as_root=True) + return True + except exception.ProcessExecutionError as exc: + if 'No such file or directory' in exc.stderr: + return False + else: + raise + + def _get_hash_str(self, base_str): + """returns string that represents hash of base_str (in a hex format)""" + return str(ctypes.c_uint64(hash(base_str)).value) -- 2.45.2