summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCsaba Henk <chenk@redhat.com>2014-08-30 14:50:13 +0200
committerCsaba Henk <chenk@redhat.com>2014-12-18 15:49:33 +0100
commit559b478e8578ab0bf591d29fcb662a9d7a1158da (patch)
tree5745a71fcf7866dd66dba7cf33b79ba1e59e1e89
parent28f311c9ad392a8d2bb21373e57f4792049dc793 (diff)
ganesha: NFS-Ganesha instrumentation2015.1.0b1
Introduce the ganesha share driver helper module which provides the GaneshaNASHelper class from which share drivers can derive NFS-Ganesha backed protocol helpers. Some utility functions are also added to ease integration. Partially implements blueprint gateway-mediated-with-ganesha Change-Id: I8683ea5eb43d7a8eaf0dfa6af3791782d32b944a
Notes
Notes (review): Verified+2: Jenkins Code-Review+2: Ben Swartzlander <ben@swartzlander.org> Workflow+1: Ben Swartzlander <ben@swartzlander.org> Code-Review+2: Valeriy Ponomaryov <vponomaryov@mirantis.com> Code-Review+1: Nilesh Bhosale <nilesh.bhosale@in.ibm.com> Code-Review+1: Rushil Chugh <rushil@netapp.com> Submitted-by: Jenkins Submitted-at: Thu, 18 Dec 2014 16:33:37 +0000 Reviewed-on: https://review.openstack.org/124635 Project: openstack/manila Branch: refs/heads/master
-rw-r--r--etc/manila/rootwrap.d/share.filters17
-rw-r--r--manila/exception.py13
-rw-r--r--manila/opts.py1
-rw-r--r--manila/share/driver.py42
-rw-r--r--manila/share/drivers/ganesha/__init__.py141
-rw-r--r--manila/share/drivers/ganesha/conf/00-base-export-template.conf48
-rw-r--r--manila/share/drivers/ganesha/manager.py344
-rw-r--r--manila/share/drivers/ganesha/utils.py76
-rw-r--r--manila/share/drivers/ibm/gpfs.py24
-rw-r--r--manila/tests/share/drivers/ganesha/__init__.py0
-rw-r--r--manila/tests/share/drivers/ganesha/test_manager.py518
-rw-r--r--manila/tests/share/drivers/ganesha/test_utils.py51
-rw-r--r--manila/tests/share/drivers/test_ganesha.py276
13 files changed, 1533 insertions, 18 deletions
diff --git a/etc/manila/rootwrap.d/share.filters b/etc/manila/rootwrap.d/share.filters
index 8425990..7df3c15 100644
--- a/etc/manila/rootwrap.d/share.filters
+++ b/etc/manila/rootwrap.d/share.filters
@@ -3,6 +3,7 @@
3 3
4[Filters] 4[Filters]
5# manila/share/drivers/glusterfs.py: 'mkdir', '%s' 5# manila/share/drivers/glusterfs.py: 'mkdir', '%s'
6# manila/share/drivers/ganesha/manager.py: 'mkdir', '-p', '%s'
6mkdir: CommandFilter, /usr/bin/mkdir, root 7mkdir: CommandFilter, /usr/bin/mkdir, root
7 8
8# manila/share/drivers/glusterfs.py: 'rm', '-rf', '%s' 9# manila/share/drivers/glusterfs.py: 'rm', '-rf', '%s'
@@ -49,6 +50,7 @@ rsync: CommandFilter, /usr/bin/rsync, root
49exportfs: CommandFilter, /usr/sbin/exportfs, root 50exportfs: CommandFilter, /usr/sbin/exportfs, root
50# Ganesha commands 51# Ganesha commands
51# manila/share/drivers/ibm/ganesha_utils.py: 'mv', '%s', '%s' 52# manila/share/drivers/ibm/ganesha_utils.py: 'mv', '%s', '%s'
53# manila/share/drivers/ganesha/manager.py: 'mv', '%s', '%s'
52mv: CommandFilter, /bin/mv, root 54mv: CommandFilter, /bin/mv, root
53# manila/share/drivers/ibm/ganesha_utils.py: 'cp', '%s', '%s' 55# manila/share/drivers/ibm/ganesha_utils.py: 'cp', '%s', '%s'
54cp: CommandFilter, /bin/cp, root 56cp: CommandFilter, /bin/cp, root
@@ -60,3 +62,18 @@ ssh: CommandFilter, /usr/bin/ssh, root
60chmod: CommandFilter, /bin/chmod, root 62chmod: CommandFilter, /bin/chmod, root
61# manila/share/drivers/ibm/ganesha_utils.py: 'service', '%s', 'restart' 63# manila/share/drivers/ibm/ganesha_utils.py: 'service', '%s', 'restart'
62service: CommandFilter, /sbin/service, root 64service: CommandFilter, /sbin/service, root
65
66# manila/share/drivers/ganesha/manager.py: 'mktemp', '-p', '%s', '-t', '%s'
67mktemp: CommandFilter, /bin/mktemp, root
68
69# manila/share/drivers/ganesha/manager.py:
70shcat: RegExpFilter, /bin/sh, root, sh, -c, cat > /.*
71
72# manila/share/drivers/ganesha/manager.py:
73dbus-addexport: RegExpFilter, /usr/bin/dbus-send, root, dbus-send, --print-reply, --system, --dest=org\.ganesha\.nfsd, /org/ganesha/nfsd/ExportMgr, org\.ganesha\.nfsd\.exportmgr\.(Add|Remove)Export, .*, .*
74
75# manila/share/drivers/ganesha/manager.py:
76dbus-removeexport: RegExpFilter, /usr/bin/dbus-send, root, dbus-send, --print-reply, --system, --dest=org\.ganesha\.nfsd, /org/ganesha/nfsd/ExportMgr, org\.ganesha\.nfsd\.exportmgr\.(Add|Remove)Export, .*
77
78# manila/share/drivers/ganesha/manager.py:
79rmconf: RegExpFilter, /bin/sh, root, sh, -c, rm /.*/\*\.conf$
diff --git a/manila/exception.py b/manila/exception.py
index e73537a..a0ed268 100644
--- a/manila/exception.py
+++ b/manila/exception.py
@@ -464,3 +464,16 @@ class GPFSException(ManilaException):
464 464
465class GPFSGaneshaException(ManilaException): 465class GPFSGaneshaException(ManilaException):
466 message = _("GPFS Ganesha exception occurred.") 466 message = _("GPFS Ganesha exception occurred.")
467
468
469class GaneshaCommandFailure(ProcessExecutionError):
470 _description = _("Ganesha management command failed.")
471
472 def __init__(self, **kw):
473 if 'description' not in kw:
474 kw['description'] = self._description
475 super(GaneshaCommandFailure, self).__init__(**kw)
476
477
478class InvalidSqliteDB(Invalid):
479 message = _("Invalid Sqlite database.")
diff --git a/manila/opts.py b/manila/opts.py
index 8f4383d..47f4dc2 100644
--- a/manila/opts.py
+++ b/manila/opts.py
@@ -95,6 +95,7 @@ _global_opt_lists = [
95 manila.scheduler.weights.capacity.capacity_weight_opts, 95 manila.scheduler.weights.capacity.capacity_weight_opts,
96 manila.service.service_opts, 96 manila.service.service_opts,
97 manila.share.api.share_api_opts, 97 manila.share.api.share_api_opts,
98 manila.share.driver.ganesha_opts,
98 manila.share.driver.share_opts, 99 manila.share.driver.share_opts,
99 manila.share.driver.ssh_opts, 100 manila.share.driver.ssh_opts,
100 manila.share.drivers.emc.driver.EMC_NAS_OPTS, 101 manila.share.drivers.emc.driver.EMC_NAS_OPTS,
diff --git a/manila/share/driver.py b/manila/share/driver.py
index 5d37d3c..a52a3cd 100644
--- a/manila/share/driver.py
+++ b/manila/share/driver.py
@@ -77,9 +77,40 @@ ssh_opts = [
77 help='Maximum number of connections in the SSH pool.'), 77 help='Maximum number of connections in the SSH pool.'),
78] 78]
79 79
80ganesha_opts = [
81 cfg.StrOpt('ganesha_config_dir',
82 default='/etc/ganesha',
83 help='Directory where Ganesha config files are stored.'),
84 cfg.StrOpt('ganesha_config_path',
85 default='$ganesha_config_dir/ganesha.conf',
86 help='Path to main Ganesha config file.'),
87 cfg.StrOpt('ganesha_nfs_export_options',
88 default='maxread = 65536, prefread = 65536',
89 help='Options to use when exporting a share using ganesha '
90 'NFS server. Note that these defaults can be overridden '
91 'when a share is created by passing metadata with key '
92 'name export_options. Also note the complete set of '
93 'default ganesha export options is specified in '
94 'ganesha_utils. (GPFS only.)'),
95 cfg.StrOpt('ganesha_service_name',
96 default='ganesha.nfsd',
97 help='Name of the ganesha nfs service.'),
98 cfg.StrOpt('ganesha_db_path',
99 default='$state_path/manila-ganesha.db',
100 help='Location of Ganesha database file. '
101 '(Ganesha module only.)'),
102 cfg.StrOpt('ganesha_export_dir',
103 default='$ganesha_config_dir/export.d',
104 help='Path to Ganesha export template. (Ganesha module only.)'),
105 cfg.StrOpt('ganesha_export_template_dir',
106 default='/etc/manila/ganesha-export-templ.d',
107 help='Path to Ganesha export template. (Ganesha module only.)'),
108]
109
80CONF = cfg.CONF 110CONF = cfg.CONF
81CONF.register_opts(share_opts) 111CONF.register_opts(share_opts)
82CONF.register_opts(ssh_opts) 112CONF.register_opts(ssh_opts)
113CONF.register_opts(ganesha_opts)
83 114
84 115
85class ExecuteMixin(object): 116class ExecuteMixin(object):
@@ -111,6 +142,14 @@ class ExecuteMixin(object):
111 time.sleep(tries ** 2) 142 time.sleep(tries ** 2)
112 143
113 144
145class GaneshaMixin(object):
146 """Augment derived classes with Ganesha configuration."""
147
148 def init_ganesha_mixin(self, *args, **kwargs):
149 if self.configuration:
150 self.configuration.append_config_values(ganesha_opts)
151
152
114class ShareDriver(object): 153class ShareDriver(object):
115 """Class defines interface of NAS driver.""" 154 """Class defines interface of NAS driver."""
116 155
@@ -129,6 +168,9 @@ class ShareDriver(object):
129 if hasattr(self, 'init_execute_mixin'): 168 if hasattr(self, 'init_execute_mixin'):
130 # Instance with 'ExecuteMixin' 169 # Instance with 'ExecuteMixin'
131 self.init_execute_mixin(*args, **kwargs) # pylint: disable=E1101 170 self.init_execute_mixin(*args, **kwargs) # pylint: disable=E1101
171 if hasattr(self, 'init_ganesha_mixin'):
172 # Instance with 'GaneshaMixin'
173 self.init_execute_mixin(*args, **kwargs) # pylint: disable=E1101
132 self.network_api = network.API(config_group_name=network_config_group) 174 self.network_api = network.API(config_group_name=network_config_group)
133 175
134 def _validate_driver_mode(self, mode): 176 def _validate_driver_mode(self, mode):
diff --git a/manila/share/drivers/ganesha/__init__.py b/manila/share/drivers/ganesha/__init__.py
new file mode 100644
index 0000000..ece4496
--- /dev/null
+++ b/manila/share/drivers/ganesha/__init__.py
@@ -0,0 +1,141 @@
1# Copyright (c) 2014 Red Hat, Inc.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import abc
17import errno
18import os
19import re
20
21from oslo.config import cfg
22import six
23
24from manila import exception
25from manila.i18n import _LI
26from manila.openstack.common import log as logging
27from manila.share.drivers.ganesha import manager as ganesha_manager
28from manila.share.drivers.ganesha import utils as ganesha_utils
29
30
31CONF = cfg.CONF
32LOG = logging.getLogger(__name__)
33
34
35@six.add_metaclass(abc.ABCMeta)
36class NASHelperBase(object):
37 """Interface to work with share."""
38
39 def __init__(self, execute, config, **kwargs):
40 self.configuration = config
41 self._execute = execute
42
43 def init_helper(self):
44 """Initializes protocol-specific NAS drivers."""
45
46 @abc.abstractmethod
47 def allow_access(self, base_path, share, access):
48 """Allow access to the host."""
49
50 @abc.abstractmethod
51 def deny_access(self, base_path, share, access):
52 """Deny access to the host."""
53
54
55class GaneshaNASHelper(NASHelperBase):
56 """Execute commands relating to Shares."""
57
58 def __init__(self, execute, config, tag='<no name>', **kwargs):
59 super(GaneshaNASHelper, self).__init__(execute, config, **kwargs)
60 self.tag = tag
61
62 confrx = re.compile('\.(conf|json)\Z')
63
64 def _load_conf_dir(self, dirpath, must_exist=True):
65 """Load Ganesha config files in dirpath in alphabetic order."""
66 try:
67 dirlist = os.listdir(dirpath)
68 except OSError as e:
69 if e.errno != errno.ENOENT or must_exist:
70 raise
71 dirlist = []
72 LOG.info(_LI('Loading Ganesha config from %s.'), dirpath)
73 conf_files = filter(self.confrx.search, dirlist)
74 conf_files.sort()
75 export_template = {}
76 for conf_file in conf_files:
77 with open(os.path.join(dirpath, conf_file)) as f:
78 ganesha_utils.patch(
79 export_template,
80 ganesha_manager.parseconf(f.read()))
81 return export_template
82
83 def init_helper(self):
84 """Initializes protocol-specific NAS drivers."""
85 self.ganesha = ganesha_manager.GaneshaManager(
86 self._execute,
87 self.tag,
88 ganesha_config_path=self.configuration.ganesha_config_path,
89 ganesha_export_dir=self.configuration.ganesha_export_dir,
90 ganesha_db_path=self.configuration.ganesha_db_path,
91 ganesha_service_name=self.configuration.ganesha_service_name)
92 system_export_template = self._load_conf_dir(
93 self.configuration.ganesha_export_template_dir,
94 must_exist=False)
95 if system_export_template:
96 self.export_template = system_export_template
97 else:
98 self.export_template = self._default_config_hook()
99
100 def _default_config_hook(self):
101 """The default export block.
102
103 Subclass this to add FSAL specific defaults.
104
105 Suggested approach: take the return value of superclass'
106 method, patch with dict containing your defaults, and
107 return the result. However, you can also provide your
108 defaults from scratch with no regard to superclass.
109 """
110
111 return self._load_conf_dir(ganesha_utils.path_from(__file__, "conf"))
112
113 def _fsal_hook(self, base_path, share, access):
114 """Subclass this to create FSAL block."""
115 return {}
116
117 def allow_access(self, base_path, share, access):
118 """Allow access to the share."""
119 if access['access_type'] != 'ip':
120 raise exception.InvalidShareAccess('Only IP access type allowed')
121 cf = {}
122 accid = access['id']
123 name = share['name']
124 export_name = "%s--%s" % (name, accid)
125 ganesha_utils.patch(cf, self.export_template, {
126 'EXPORT': {
127 'Export_Id': self.ganesha.get_export_id(),
128 'Path': os.path.join(base_path, name),
129 'Pseudo': os.path.join(base_path, export_name),
130 'Tag': accid,
131 'CLIENT': {
132 'Clients': access['access_to']
133 },
134 'FSAL': self._fsal_hook(base_path, share, access)
135 }
136 })
137 self.ganesha.add_export(export_name, cf)
138
139 def deny_access(self, base_path, share, access):
140 """Deny access to the share."""
141 self.ganesha.remove_export("%s--%s" % (share['name'], access['id']))
diff --git a/manila/share/drivers/ganesha/conf/00-base-export-template.conf b/manila/share/drivers/ganesha/conf/00-base-export-template.conf
new file mode 100644
index 0000000..3220370
--- /dev/null
+++ b/manila/share/drivers/ganesha/conf/00-base-export-template.conf
@@ -0,0 +1,48 @@
1# This is a Ganesha config template.
2# Syntactically, a valid Ganesha config
3# file, but some values in it are stubs.
4# Fields that have stub values are managed
5# by Manila; the stubs are of two kinds:
6# - @config:
7# value will be taken from Manila config
8# - @runtime:
9# value will be determined at runtime
10# User is free to set Ganesha parameters
11# which are not reserved to Manila by
12# stubbing.
13
14EXPORT {
15 # Each EXPORT must have a unique Export_Id.
16 Export_Id = @runtime;
17
18 # The directory in the exported file system this export
19 # is rooted on.
20 Path = @runtime;
21
22 # FSAL, Ganesha's module component
23 FSAL {
24 # FSAL name
25 Name = @config;
26 }
27
28 # Path of export in the NFSv4 pseudo filesystem
29 Pseudo = @runtime;
30
31 # RPC security flavor, one of none, sys, krb5{,i,p}
32 SecType = sys;
33
34 # Alternative export identifier for NFSv3
35 Tag = @runtime;
36
37 # Client specification
38 CLIENT {
39 # Comma separated list of clients
40 Clients = @runtime;
41
42 # Access type, one of RW, RO, MDONLY, MDONLY_RO, NONE
43 Access_Type = RW;
44 }
45
46 # User id squashing, one of None, Root, All
47 Squash = None;
48}
diff --git a/manila/share/drivers/ganesha/manager.py b/manila/share/drivers/ganesha/manager.py
new file mode 100644
index 0000000..b953fdc
--- /dev/null
+++ b/manila/share/drivers/ganesha/manager.py
@@ -0,0 +1,344 @@
1# Copyright (c) 2014 Red Hat, Inc.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import os
17import pipes
18import re
19import sys
20
21from oslo.serialization import jsonutils
22import six
23
24from manila import exception
25from manila.i18n import _
26from manila.i18n import _LE
27from manila.openstack.common import log as logging
28from manila.share.drivers.ganesha import utils as ganesha_utils
29from manila import utils
30
31
32LOG = logging.getLogger(__name__)
33IWIDTH = 4
34
35
36def _conf2json(conf):
37 """Convert Ganesha config to JSON."""
38
39 # tokenize config string
40 token_list = [six.StringIO()]
41 state = {
42 'in_quote': False,
43 'in_comment': False,
44 'escape': False,
45 }
46
47 cbk = []
48 for char in conf:
49 if state['in_quote']:
50 if not state['escape']:
51 if char == '"':
52 state['in_quote'] = False
53 cbk.append(lambda: token_list.append(six.StringIO()))
54 elif char == '\\':
55 cbk.append(lambda: state.update({'escape': True}))
56 else:
57 if char == "#":
58 state['in_comment'] = True
59 if state['in_comment']:
60 if char == "\n":
61 state['in_comment'] = False
62 else:
63 if char == '"':
64 token_list.append(six.StringIO())
65 state['in_quote'] = True
66 state['escape'] = False
67 if not state['in_comment']:
68 token_list[-1].write(char)
69 while cbk:
70 cbk.pop(0)()
71
72 if state['in_quote']:
73 raise RuntimeError("Unterminated quoted string")
74
75 # jsonify tokens
76 js_token_list = ["{"]
77 for tok in token_list:
78 tok = tok.getvalue()
79
80 if tok[0] == '"':
81 js_token_list.append(tok)
82 continue
83
84 for pat, s in [
85 # add omitted "=" signs to block openings
86 ('([^=\s])\s*{', '\\1={'),
87 # delete trailing semicolons in blocks
88 (';\s*}', '}'),
89 # add omitted semicolons after blocks
90 ('}\s*([^}\s])', '};\\1'),
91 # separate syntactically significant characters
92 ('([;{}=])', ' \\1 ')]:
93 tok = re.sub(pat, s, tok)
94
95 # map tokens to JSON equivalents
96 for word in tok.split():
97 if word == "=":
98 word = ":"
99 elif word == ";":
100 word = ','
101 elif (word in ['{', '}'] or
102 re.search('\A-?[1-9]\d*(\.\d+)?\Z', word)):
103 pass
104 else:
105 word = jsonutils.dumps(word)
106 js_token_list.append(word)
107 js_token_list.append("}")
108
109 # group quouted strings
110 token_grp_list = []
111 for tok in js_token_list:
112 if tok[0] == '"':
113 if not (token_grp_list and isinstance(token_grp_list[-1], list)):
114 token_grp_list.append([])
115 token_grp_list[-1].append(tok)
116 else:
117 token_grp_list.append(tok)
118
119 # process quoted string groups by joining them
120 js_token_list2 = []
121 for x in token_grp_list:
122 if isinstance(x, list):
123 x = ''.join(['"'] + [tok[1:-1] for tok in x] + ['"'])
124 js_token_list2.append(x)
125
126 return ''.join(js_token_list2)
127
128
129def _dump_to_conf(confdict, out=sys.stdout, indent=0):
130 """Output confdict in Ganesha config format."""
131 if isinstance(confdict, dict):
132 for k, v in six.iteritems(confdict):
133 if v is None:
134 continue
135 out.write(' ' * (indent * IWIDTH) + k + ' ')
136 if isinstance(v, dict):
137 out.write("{\n")
138 _dump_to_conf(v, out, indent + 1)
139 out.write(' ' * (indent * IWIDTH) + '}')
140 else:
141 out.write('= ')
142 _dump_to_conf(v, out, indent)
143 out.write(';')
144 out.write('\n')
145 else:
146 dj = jsonutils.dumps(confdict)
147 if confdict == dj[1:-1]:
148 out.write(confdict)
149 else:
150 out.write(dj)
151
152
153def parseconf(conf):
154 """Parse Ganesha config.
155
156 Both native format and JSON are supported.
157 """
158
159 try:
160 # allow config to be specified in JSON --
161 # for sake of people who might feel Ganesha config foreign.
162 d = jsonutils.loads(conf)
163 except ValueError:
164 d = jsonutils.loads(_conf2json(conf))
165 return d
166
167
168def mkconf(confdict):
169 """Create Ganesha config string from confdict."""
170 s = six.StringIO()
171 _dump_to_conf(confdict, s)
172 return s.getvalue()
173
174
175class GaneshaManager(object):
176 """Ganesha instrumentation class."""
177
178 def __init__(self, execute, tag, **kwargs):
179 self.confrx = re.compile('\.conf\Z')
180 self.ganesha_config_path = kwargs['ganesha_config_path']
181 self.tag = tag
182
183 def _execute(*args, **kwargs):
184 msg = kwargs.pop('message', args[0])
185 makelog = kwargs.pop('makelog', True)
186 try:
187 return execute(*args, **kwargs)
188 except exception.ProcessExecutionError as e:
189 if makelog:
190 LOG.error(
191 _LE("Error while executing management command on "
192 "Ganesha node %(tag)s: %(msg)s."),
193 {'tag': tag, 'msg': msg})
194 raise exception.GaneshaCommandFailure(
195 stdout=e.stdout, stderr=e.stderr, exit_code=e.exit_code,
196 cmd=e.cmd)
197 self.execute = _execute
198 self.ganesha_export_dir = kwargs['ganesha_export_dir']
199 self.execute('mkdir', '-p', self.ganesha_export_dir)
200 self.ganesha_db_path = kwargs['ganesha_db_path']
201 self.execute('mkdir', '-p', os.path.dirname(self.ganesha_db_path))
202 self.ganesha_service = kwargs['ganesha_service_name']
203 # Here we are to make sure that an SQLite database of the
204 # required scheme exists at self.ganesha_db_path.
205 # The following command gets us there -- provided the file
206 # does not yet exist (otherwise it just fails). However,
207 # we don't care about this condition, we just execute the
208 # command unconditionally (ignoring failure). Instead we
209 # directly query the db right after, to check its validity.
210 self.execute("sqlite3", self.ganesha_db_path,
211 'create table ganesha(key varchar(20) primary key, '
212 'value int); insert into ganesha values("exportid", '
213 '100);', run_as_root=False, check_exit_code=False)
214 self.get_export_id(bump=False)
215 # Starting from empty state. State will be rebuilt in a later
216 # stage of service initalization.
217 self.reset_exports()
218 self.restart_service()
219
220 def _getpath(self, name):
221 """Get the path of config file for name."""
222 return os.path.join(self.ganesha_export_dir, name + ".conf")
223
224 def _write_file(self, path, data):
225 """Write data to path atomically."""
226 dirpath, fname = (getattr(os.path, q + "name")(path) for q in
227 ("dir", "base"))
228 tmpf = self.execute('mktemp', '-p', dirpath, "-t",
229 fname + ".XXXXXX")[0][:-1]
230 self.execute('sh', '-c', 'cat > ' + pipes.quote(tmpf),
231 process_input=data, message='writing ' + tmpf)
232 self.execute('mv', tmpf, path)
233
234 def _write_conf_file(self, name, data):
235 """Write data to config file for name atomically."""
236 path = self._getpath(name)
237 self._write_file(path, data)
238 return path
239
240 def _mkindex(self):
241 """Generate the index file for current exports."""
242 @utils.synchronized("ganesha-index-" + self.tag, external=True)
243 def _mkindex():
244 files = filter(lambda f: self.confrx.search(f) and
245 f != "INDEX.conf",
246 self.execute('ls', self.ganesha_export_dir,
247 run_as_root=False)[0].split("\n"))
248 index = "".join(map(lambda f: "%include " + os.path.join(
249 self.ganesha_export_dir, f) + "\n", files))
250 self._write_conf_file("INDEX", index)
251 _mkindex()
252
253 def _read_export_file(self, name):
254 """Return the dict of the export identified by name."""
255 return parseconf(self.execute("cat", self._getpath(name),
256 message='reading export ' + name)[0])
257
258 def _write_export_file(self, name, confdict):
259 """Write confdict to the export file of name."""
260 for k, v in ganesha_utils.walk(confdict):
261 # values in the export block template that need to be
262 # filled in by Manila are pre-fixed by '@'
263 if isinstance(v, basestring) and v[0] == '@':
264 msg = _("Incomplete export block: value %(val)s of attribute "
265 "%(key)s is a stub.") % {'key': k, 'val': v}
266 raise exception.InvalidParameterValue(err=msg)
267 return self._write_conf_file(name, mkconf(confdict))
268
269 def _rm_export_file(self, name):
270 """Remove export file of name."""
271 self.execute("rm", self._getpath(name))
272
273 def _dbus_send_ganesha(self, method, *args, **kwargs):
274 """Send a message to Ganesha via dbus."""
275 service = kwargs.pop("service", "exportmgr")
276 self.execute("dbus-send", "--print-reply", "--system",
277 "--dest=org.ganesha.nfsd", "/org/ganesha/nfsd/ExportMgr",
278 "org.ganesha.nfsd.%s.%s" % (service, method), *args,
279 message='dbus call %s.%s' % (service, method), **kwargs)
280
281 def _remove_export_dbus(self, xid):
282 """Remove an export from Ganesha runtime with given export id."""
283 self._dbus_send_ganesha("RemoveExport", "uint16:%d" % xid)
284
285 def add_export(self, name, confdict):
286 """Add an export to Ganesha specified by confdict."""
287 xid = confdict["EXPORT"]["Export_Id"]
288 undos = []
289 _mkindex_called = False
290 try:
291 path = self._write_export_file(name, confdict)
292 undos.append(lambda: self._rm_export_file(name))
293
294 self._dbus_send_ganesha("AddExport", "string:" + path,
295 "string:EXPORT(Export_Id=%d)" % xid)
296 undos.append(lambda: self._remove_export_dbus(xid))
297
298 _mkindex_called = True
299 self._mkindex()
300 except Exception:
301 for u in undos:
302 u()
303 if not _mkindex_called:
304 self._mkindex()
305 raise
306
307 def remove_export(self, name):
308 """Remove an export from Ganesha."""
309 try:
310 confdict = self._read_export_file(name)
311 self._remove_export_dbus(confdict["EXPORT"]["Export_Id"])
312 finally:
313 self._rm_export_file(name)
314 self._mkindex()
315
316 def get_export_id(self, bump=True):
317 """Get a new export id."""
318 # XXX overflowing the export id (16 bit unsigned integer)
319 # is not handled
320 if bump:
321 bumpcode = 'update ganesha set value = value + 1;'
322 else:
323 bumpcode = ''
324 out = self.execute(
325 "sqlite3", self.ganesha_db_path,
326 bumpcode + 'select * from ganesha where key = "exportid";',
327 run_as_root=False)[0]
328 match = re.search('\Aexportid\|(\d+)$', out)
329 if not match:
330 LOG.error(_LE("Invalid export database on "
331 "Ganesha node %(tag)s: %(db)s."),
332 {'tag': self.tag, 'db': self.ganesha_db_path})
333 raise exception.InvalidSqliteDB()
334 return int(match.groups()[0])
335
336 def restart_service(self):
337 """Restart the Ganesha service."""
338 self.execute("service", self.ganesha_service, "restart")
339
340 def reset_exports(self):
341 """Delete all export files."""
342 self.execute('sh', '-c',
343 'rm %s/*.conf' % pipes.quote(self.ganesha_export_dir))
344 self._mkindex()
diff --git a/manila/share/drivers/ganesha/utils.py b/manila/share/drivers/ganesha/utils.py
new file mode 100644
index 0000000..c161eee
--- /dev/null
+++ b/manila/share/drivers/ganesha/utils.py
@@ -0,0 +1,76 @@
1# Copyright (c) 2014 Red Hat, Inc.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import os
17import pipes
18
19from oslo_concurrency import processutils
20import six
21
22from manila import utils
23
24
25def patch(base, *overlays):
26 """Recursive dictionary patching."""
27 for ovl in overlays:
28 for k, v in six.iteritems(ovl):
29 if isinstance(v, dict) and isinstance(base.get(k), dict):
30 patch(base[k], v)
31 else:
32 base[k] = v
33 return base
34
35
36def walk(dct):
37 """Recursive iteration over dictionary."""
38 for k, v in six.iteritems(dct):
39 if isinstance(v, dict):
40 for w in walk(v):
41 yield w
42 else:
43 yield k, v
44
45
46class RootExecutor(object):
47 """Execute wrapper defaulting to root exection."""
48
49 def __init__(self, execute=utils.execute):
50 self.execute = execute
51
52 def __call__(self, *args, **kwargs):
53 exkwargs = {"run_as_root": True}
54 exkwargs.update(kwargs)
55 return self.execute(*args, **exkwargs)
56
57
58class SSHExecutor(object):
59 """Callable encapsulating exec through ssh."""
60
61 def __init__(self, *args, **kwargs):
62 self.pool = utils.SSHPool(*args, **kwargs)
63
64 def __call__(self, *args, **kwargs):
65 cmd = ' '.join(pipes.quote(a) for a in args)
66 ssh = self.pool.get()
67 try:
68 ret = processutils.ssh_execute(ssh, cmd, **kwargs)
69 finally:
70 self.pool.put(ssh)
71 return ret
72
73
74def path_from(fpath, *rpath):
75 """Return the join of the dir of fpath and rpath in absolute form."""
76 return os.path.join(os.path.abspath(os.path.dirname(fpath)), *rpath)
diff --git a/manila/share/drivers/ibm/gpfs.py b/manila/share/drivers/ibm/gpfs.py
index 85d95c5..5a7063c 100644
--- a/manila/share/drivers/ibm/gpfs.py
+++ b/manila/share/drivers/ibm/gpfs.py
@@ -101,22 +101,6 @@ gpfs_share_opts = [
101 'NFS server. Note that these defaults can be overridden ' 101 'NFS server. Note that these defaults can be overridden '
102 'when a share is created by passing metadata with key ' 102 'when a share is created by passing metadata with key '
103 'name export_options.')), 103 'name export_options.')),
104 cfg.StrOpt('gnfs_export_options',
105 default=('maxread = 65536, prefread = 65536'),
106 help=('Options to use when exporting a share using ganesha '
107 'NFS server. Note that these defaults can be overridden '
108 'when a share is created by passing metadata with key '
109 'name export_options. Also note the complete set of '
110 'default ganesha export options is specified in '
111 'ganesha_utils.')),
112 cfg.StrOpt('ganesha_config_path',
113 default='/etc/ganesha/ganesha_exports.conf',
114 help=('Path to ganesha export config file. The config file '
115 'may also contain non-export configuration data but it '
116 'must be placed before the EXPORT clauses.')),
117 cfg.StrOpt('ganesha_service_name',
118 default='ganesha.nfsd',
119 help=('Name of the ganesha nfs service.')),
120] 104]
121 105
122 106
@@ -124,7 +108,9 @@ CONF = cfg.CONF
124CONF.register_opts(gpfs_share_opts) 108CONF.register_opts(gpfs_share_opts)
125 109
126 110
127class GPFSShareDriver(driver.ExecuteMixin, driver.ShareDriver): 111class GPFSShareDriver(driver.ExecuteMixin, driver.GaneshaMixin,
112 driver.ShareDriver):
113
128 """GPFS Share Driver. 114 """GPFS Share Driver.
129 115
130 Executes commands relating to Shares. 116 Executes commands relating to Shares.
@@ -696,7 +682,9 @@ class GNFSHelper(NASHelperBase):
696 def __init__(self, execute, config_object): 682 def __init__(self, execute, config_object):
697 super(GNFSHelper, self).__init__(execute, config_object) 683 super(GNFSHelper, self).__init__(execute, config_object)
698 self.default_export_options = dict() 684 self.default_export_options = dict()
699 for m in AVPATTERN.finditer(self.configuration.gnfs_export_options): 685 for m in AVPATTERN.finditer(
686 self.configuration.ganesha_nfs_export_options
687 ):
700 self.default_export_options[m.group('attr')] = m.group('val') 688 self.default_export_options[m.group('attr')] = m.group('val')
701 689
702 def _get_export_options(self, share): 690 def _get_export_options(self, share):
diff --git a/manila/tests/share/drivers/ganesha/__init__.py b/manila/tests/share/drivers/ganesha/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/manila/tests/share/drivers/ganesha/__init__.py
diff --git a/manila/tests/share/drivers/ganesha/test_manager.py b/manila/tests/share/drivers/ganesha/test_manager.py
new file mode 100644
index 0000000..f0d615a
--- /dev/null
+++ b/manila/tests/share/drivers/ganesha/test_manager.py
@@ -0,0 +1,518 @@
1# Copyright (c) 2014 Red Hat, Inc.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import contextlib
17import re
18
19import mock
20from oslo.serialization import jsonutils
21import six
22
23from manila import exception
24from manila.share.drivers.ganesha import manager
25from manila import test
26from manila import utils
27
28
29test_export_id = 101
30test_name = 'fakefile'
31test_path = '/fakedir0/export.d/fakefile.conf'
32test_ganesha_cnf = """EXPORT {
33 Export_Id = 101;
34 CLIENT {
35 Clients = ip1;
36 }
37}"""
38test_dict_unicode = {
39 u'EXPORT': {
40 u'Export_Id': 101,
41 u'CLIENT': {u'Clients': u"ip1"}
42 }
43}
44test_dict_str = {
45 'EXPORT': {
46 'Export_Id': 101,
47 'CLIENT': {'Clients': "ip1"}
48 }
49}
50
51manager_fake_kwargs = {
52 'ganesha_config_path': '/fakedir0/fakeconfig',
53 'ganesha_db_path': '/fakedir1/fake.db',
54 'ganesha_export_dir': '/fakedir0/export.d',
55 'ganesha_service_name': 'ganesha.fakeservice'
56}
57
58
59class GaneshaConfigTests(test.TestCase):
60 """Tests Ganesha config file format convertor functions."""
61
62 ref_ganesha_cnf = """EXPORT {
63 CLIENT {
64 Clients = ip1;
65 }
66 Export_Id = 101;
67}"""
68
69 @staticmethod
70 def conf_mangle(*confs):
71 """A "mangler" for the conf format.
72
73 Its purpose is to transform conf data in a way so that semantically
74 equivalent confs yield identical results. Besides this objective
75 criteria, we seek a good trade-off between the following
76 requirements:
77 - low lossiness;
78 - low code complexity.
79 """
80 def _conf_mangle(conf):
81 # split to expressions by the delimiter ";"
82 # (braces are forced to be treated as expressions
83 # by sandwiching them in ";"-s)
84 conf = re.sub('[{}]', ';\g<0>;', conf).split(';')
85 # whitespace-split expressions to tokens with
86 # (equality is forced to be treated as token by
87 # sandwiching in space)
88 conf = map(lambda l: l.replace("=", " = ").split(), conf)
89 # get rid of by-product empty lists (derived from superflouous
90 # ";"-s that might have crept in due to "sandwiching")
91 conf = map(lambda x: x, conf)
92 # handle the non-deterministic order of confs
93 conf.sort()
94 return conf
95
96 return (_conf_mangle(conf) for conf in confs)
97
98 def test_conf2json(self):
99 test_ganesha_cnf_with_comment = """EXPORT {
100# fake_export_block
101 Export_Id = 101;
102 CLIENT {
103 Clients = ip1;
104 }
105}"""
106 ret = manager._conf2json(test_ganesha_cnf_with_comment)
107 self.assertEqual(test_dict_unicode, jsonutils.loads(ret))
108
109 def test_parseconf_ganesha_cnf_input(self):
110 ret = manager.parseconf(test_ganesha_cnf)
111 self.assertEqual(test_dict_unicode, ret)
112
113 def test_parseconf_json_input(self):
114 ret = manager.parseconf(jsonutils.dumps(test_dict_str))
115 self.assertEqual(test_dict_unicode, ret)
116
117 def test_dump_to_conf(self):
118 ganesha_cnf = six.StringIO()
119 manager._dump_to_conf(test_dict_str, ganesha_cnf)
120 self.assertEqual(*self.conf_mangle(self.ref_ganesha_cnf,
121 ganesha_cnf.getvalue()))
122
123 def test_mkconf(self):
124 ganesha_cnf = manager.mkconf(test_dict_str)
125 self.assertEqual(*self.conf_mangle(self.ref_ganesha_cnf,
126 ganesha_cnf))
127
128
129class GaneshaManagerTestCase(test.TestCase):
130 """Tests GaneshaManager."""
131
132 def setUp(self):
133 super(GaneshaManagerTestCase, self).setUp()
134 self._execute = mock.Mock(return_value=('', ''))
135 with contextlib.nested(
136 mock.patch.object(manager.GaneshaManager, 'get_export_id',
137 return_value=100),
138 mock.patch.object(manager.GaneshaManager, 'reset_exports'),
139 mock.patch.object(manager.GaneshaManager, 'restart_service')
140 ) as (self.mock_get_export_id, self.mock_reset_exports,
141 self.mock_restart_service):
142 self._manager = manager.GaneshaManager(
143 self._execute, 'faketag', **manager_fake_kwargs)
144 self.stubs.Set(utils, 'synchronized',
145 mock.Mock(return_value=lambda f: f))
146
147 def test_init(self):
148 self.stubs.Set(self._manager, 'reset_exports', mock.Mock())
149 self.stubs.Set(self._manager, 'restart_service', mock.Mock())
150 self.assertEqual('/fakedir0/fakeconfig',
151 self._manager.ganesha_config_path)
152 self.assertEqual('faketag', self._manager.tag)
153 self.assertEqual('/fakedir0/export.d',
154 self._manager.ganesha_export_dir)
155 self.assertEqual('/fakedir1/fake.db', self._manager.ganesha_db_path)
156 self.assertEqual('ganesha.fakeservice', self._manager.ganesha_service)
157 self.assertEqual(
158 [mock.call('mkdir', '-p', self._manager.ganesha_export_dir),
159 mock.call('mkdir', '-p', '/fakedir1'),
160 mock.call('sqlite3', self._manager.ganesha_db_path,
161 'create table ganesha(key varchar(20) primary key, '
162 'value int); insert into ganesha values("exportid", '
163 '100);', run_as_root=False, check_exit_code=False)],
164 self._execute.call_args_list)
165 self.mock_get_export_id.assert_called_once_with(bump=False)
166 self.mock_reset_exports.assert_called_once_with()
167 self.mock_restart_service.assert_called_once_with()
168
169 def test_init_execute_error_log_message(self):
170 fake_args = ('foo', 'bar')
171
172 def raise_exception(*args, **kwargs):
173 if args == fake_args:
174 raise exception.GaneshaCommandFailure()
175
176 test_execute = mock.Mock(side_effect=raise_exception)
177 self.stubs.Set(manager.LOG, 'error', mock.Mock())
178 with contextlib.nested(
179 mock.patch.object(manager.GaneshaManager, 'get_export_id',
180 return_value=100),
181 mock.patch.object(manager.GaneshaManager, 'reset_exports'),
182 mock.patch.object(manager.GaneshaManager, 'restart_service')
183 ) as (self.mock_get_export_id, self.mock_reset_exports,
184 self.mock_restart_service):
185 test_manager = manager.GaneshaManager(
186 test_execute, 'faketag', **manager_fake_kwargs)
187 self.assertRaises(
188 exception.GaneshaCommandFailure,
189 test_manager.execute,
190 *fake_args, message='fakemsg')
191 manager.LOG.error.assert_called_once_with(
192 mock.ANY, {'tag': 'faketag', 'msg': 'fakemsg'})
193
194 def test_init_execute_error_no_log_message(self):
195 fake_args = ('foo', 'bar')
196
197 def raise_exception(*args, **kwargs):
198 if args == fake_args:
199 raise exception.GaneshaCommandFailure()
200
201 test_execute = mock.Mock(side_effect=raise_exception)
202 self.stubs.Set(manager.LOG, 'error', mock.Mock())
203 with contextlib.nested(
204 mock.patch.object(manager.GaneshaManager, 'get_export_id',
205 return_value=100),
206 mock.patch.object(manager.GaneshaManager, 'reset_exports'),
207 mock.patch.object(manager.GaneshaManager, 'restart_service')
208 ) as (self.mock_get_export_id, self.mock_reset_exports,
209 self.mock_restart_service):
210 test_manager = manager.GaneshaManager(
211 test_execute, 'faketag', **manager_fake_kwargs)
212 self.assertRaises(
213 exception.GaneshaCommandFailure,
214 test_manager.execute,
215 *fake_args, message='fakemsg', makelog=False)
216 self.assertFalse(manager.LOG.error.called)
217
218 def test_ganesha_export_dir(self):
219 self.assertEqual(
220 '/fakedir0/export.d', self._manager.ganesha_export_dir)
221
222 def test_getpath(self):
223 self.assertEqual(
224 '/fakedir0/export.d/fakefile.conf',
225 self._manager._getpath('fakefile'))
226
227 def test_write_file(self):
228 test_data = 'fakedata'
229 self.stubs.Set(manager.pipes, 'quote',
230 mock.Mock(return_value='fakefile.conf.RANDOM'))
231 test_args = [
232 ('mktemp', '-p', '/fakedir0/export.d', '-t',
233 'fakefile.conf.XXXXXX'),
234 ('sh', '-c', 'cat > fakefile.conf.RANDOM'),
235 ('mv', 'fakefile.conf.RANDOM', test_path)]
236 test_kwargs = {
237 'process_input': test_data,
238 'message': 'writing fakefile.conf.RANDOM'
239 }
240
241 def return_tmpfile(*args, **kwargs):
242 if args == test_args[0]:
243 return ('fakefile.conf.RANDOM\n', '')
244
245 self.stubs.Set(self._manager, 'execute',
246 mock.Mock(side_effect=return_tmpfile))
247 self._manager._write_file(test_path, test_data)
248 self._manager.execute.assert_has_calls([
249 mock.call(*test_args[0]),
250 mock.call(*test_args[1], **test_kwargs),
251 mock.call(*test_args[2])])
252 manager.pipes.quote.assert_called_once_with('fakefile.conf.RANDOM')
253
254 def test_write_conf_file(self):
255 test_data = 'fakedata'
256 self.stubs.Set(self._manager, '_getpath',
257 mock.Mock(return_value=test_path))
258 self.stubs.Set(self._manager, '_write_file', mock.Mock())
259 ret = self._manager._write_conf_file(test_name, test_data)
260 self.assertEqual(test_path, ret)
261 self._manager._getpath.assert_called_once_with(test_name)
262 self._manager._write_file.assert_called_once_with(
263 test_path, test_data)
264
265 def test_mkindex(self):
266 test_ls_output = 'INDEX.conf\nfakefile.conf\nfakefile.txt'
267 test_index = '%include /fakedir0/export.d/fakefile.conf\n'
268 self.stubs.Set(self._manager, 'execute',
269 mock.Mock(return_value=(test_ls_output, '')))
270 self.stubs.Set(self._manager, '_write_conf_file', mock.Mock())
271 ret = self._manager._mkindex()
272 self._manager.execute.assert_called_once_with(
273 'ls', '/fakedir0/export.d', run_as_root=False)
274 self._manager._write_conf_file.assert_called_once_with(
275 'INDEX', test_index)
276 self.assertEqual(None, ret)
277
278 def test_read_export_file(self):
279 test_args = ('cat', test_path)
280 test_kwargs = {'message': 'reading export fakefile'}
281 self.stubs.Set(self._manager, '_getpath',
282 mock.Mock(return_value=test_path))
283 self.stubs.Set(self._manager, 'execute',
284 mock.Mock(return_value=(test_ganesha_cnf,)))
285 self.stubs.Set(manager, 'parseconf',
286 mock.Mock(return_value=test_dict_unicode))
287 ret = self._manager._read_export_file(test_name)
288 self._manager._getpath.assert_called_once_with(test_name)
289 self._manager.execute.assert_called_once_with(
290 *test_args, **test_kwargs)
291 manager.parseconf.assert_called_once_with(test_ganesha_cnf)
292 self.assertEqual(test_dict_unicode, ret)
293
294 def test_write_export_file(self):
295 self.stubs.Set(manager, 'mkconf',
296 mock.Mock(return_value=test_ganesha_cnf))
297 self.stubs.Set(self._manager, '_write_conf_file',
298 mock.Mock(return_value=test_path))
299 ret = self._manager._write_export_file(test_name, test_dict_str)
300 manager.mkconf.assert_called_once_with(test_dict_str)
301 self._manager._write_conf_file.assert_called_once_with(
302 test_name, test_ganesha_cnf)
303 self.assertEqual(test_path, ret)
304
305 def test_write_export_file_error_incomplete_export_block(self):
306
307 test_errordict = {
308 u'EXPORT': {
309 u'Export_Id': '@config',
310 u'CLIENT': {u'Clients': u"'ip1','ip2'"}
311 }
312 }
313 self.stubs.Set(manager, 'mkconf',
314 mock.Mock(return_value=test_ganesha_cnf))
315 self.stubs.Set(self._manager, '_write_conf_file',
316 mock.Mock(return_value=test_path))
317 self.assertRaises(exception.InvalidParameterValue,
318 self._manager._write_export_file,
319 test_name, test_errordict)
320 self.assertFalse(manager.mkconf.called)
321 self.assertFalse(self._manager._write_conf_file.called)
322
323 def test_rm_export_file(self):
324 self.stubs.Set(self._manager, 'execute',
325 mock.Mock(return_value=('', '')))
326 self.stubs.Set(self._manager, '_getpath',
327 mock.Mock(return_value=test_path))
328 ret = self._manager._rm_export_file(test_name)
329 self._manager._getpath.assert_called_once_with(test_name)
330 self._manager.execute.assert_called_once_with('rm', test_path)
331 self.assertEqual(None, ret)
332
333 def test_dbus_send_ganesha(self):
334 test_args = ('arg1', 'arg2')
335 test_kwargs = {'key': 'value'}
336 self.stubs.Set(self._manager, 'execute',
337 mock.Mock(return_value=('', '')))
338 ret = self._manager._dbus_send_ganesha('fakemethod', *test_args,
339 **test_kwargs)
340 self._manager.execute.assert_called_once_with(
341 'dbus-send', '--print-reply', '--system',
342 '--dest=org.ganesha.nfsd', '/org/ganesha/nfsd/ExportMgr',
343 'org.ganesha.nfsd.exportmgr.fakemethod',
344 *test_args, message='dbus call exportmgr.fakemethod',
345 **test_kwargs)
346 self.assertEqual(None, ret)
347
348 def test_remove_export_dbus(self):
349 self.stubs.Set(self._manager, '_dbus_send_ganesha',
350 mock.Mock())
351 ret = self._manager._remove_export_dbus(test_export_id)
352 self._manager._dbus_send_ganesha.assert_called_once_with(
353 'RemoveExport', 'uint16:101')
354 self.assertEqual(None, ret)
355
356 def test_add_export(self):
357 self.stubs.Set(self._manager, '_write_export_file',
358 mock.Mock(return_value=test_path))
359 self.stubs.Set(self._manager, '_dbus_send_ganesha', mock.Mock())
360 self.stubs.Set(self._manager, '_mkindex', mock.Mock())
361 ret = self._manager.add_export(test_name, test_dict_str)
362 self._manager._write_export_file.assert_called_once_with(
363 test_name, test_dict_str)
364 self._manager._dbus_send_ganesha.assert_called_once_with(
365 'AddExport', 'string:' + test_path,
366 'string:EXPORT(Export_Id=101)')
367 self._manager._mkindex.assert_called_once_with()
368 self.assertEqual(None, ret)
369
370 def test_add_export_error_during_mkindex(self):
371 self.stubs.Set(self._manager, '_write_export_file',
372 mock.Mock(return_value=test_path))
373 self.stubs.Set(self._manager, '_dbus_send_ganesha', mock.Mock())
374 self.stubs.Set(self._manager, '_mkindex',
375 mock.Mock(side_effect=exception.GaneshaCommandFailure))
376 self.stubs.Set(self._manager, '_rm_export_file', mock.Mock())
377 self.stubs.Set(self._manager, '_remove_export_dbus', mock.Mock())
378 self.assertRaises(exception.GaneshaCommandFailure,
379 self._manager.add_export, test_name, test_dict_str)
380 self._manager._write_export_file.assert_called_once_with(
381 test_name, test_dict_str)
382 self._manager._dbus_send_ganesha.assert_called_once_with(
383 'AddExport', 'string:' + test_path,
384 'string:EXPORT(Export_Id=101)')
385 self._manager._mkindex.assert_called_once_with()
386 self._manager._rm_export_file.assert_called_once_with(test_name)
387 self._manager._remove_export_dbus.assert_called_once_with(
388 test_export_id)
389
390 def test_add_export_error_during_write_export_file(self):
391 self.stubs.Set(self._manager, '_write_export_file',
392 mock.Mock(side_effect=exception.GaneshaCommandFailure))
393 self.stubs.Set(self._manager, '_dbus_send_ganesha', mock.Mock())
394 self.stubs.Set(self._manager, '_mkindex', mock.Mock())
395 self.stubs.Set(self._manager, '_rm_export_file', mock.Mock())
396 self.stubs.Set(self._manager, '_remove_export_dbus', mock.Mock())
397 self.assertRaises(exception.GaneshaCommandFailure,
398 self._manager.add_export, test_name, test_dict_str)
399 self._manager._write_export_file.assert_called_once_with(
400 test_name, test_dict_str)
401 self.assertFalse(self._manager._dbus_send_ganesha.called)
402 self._manager._mkindex.assert_called_once_with()
403 self.assertFalse(self._manager._rm_export_file.called)
404 self.assertFalse(self._manager._remove_export_dbus.called)
405
406 def test_add_export_error_during_dbus_send_ganesha(self):
407 self.stubs.Set(self._manager, '_write_export_file',
408 mock.Mock(return_value=test_path))
409 self.stubs.Set(self._manager, '_dbus_send_ganesha',
410 mock.Mock(side_effect=exception.GaneshaCommandFailure))
411 self.stubs.Set(self._manager, '_mkindex',
412 mock.Mock())
413 self.stubs.Set(self._manager, '_rm_export_file', mock.Mock())
414 self.stubs.Set(self._manager, '_remove_export_dbus', mock.Mock())
415 self.assertRaises(exception.GaneshaCommandFailure,
416 self._manager.add_export, test_name, test_dict_str)
417 self._manager._write_export_file.assert_called_once_with(
418 test_name, test_dict_str)
419 self._manager._dbus_send_ganesha.assert_called_once_with(
420 'AddExport', 'string:' + test_path,
421 'string:EXPORT(Export_Id=101)')
422 self._manager._rm_export_file.assert_called_once_with(test_name)
423 self._manager._mkindex.assert_called_once_with()
424 self.assertFalse(self._manager._remove_export_dbus.called)
425
426 def test_remove_export(self):
427 self.stubs.Set(self._manager, '_read_export_file',
428 mock.Mock(return_value=test_dict_unicode))
429 methods = ('_remove_export_dbus', '_rm_export_file', '_mkindex')
430 for method in methods:
431 self.stubs.Set(self._manager, method, mock.Mock())
432 ret = self._manager.remove_export(test_name)
433 self._manager._read_export_file.assert_called_once_with(test_name)
434 self._manager._remove_export_dbus.assert_called_once_with(
435 test_dict_unicode['EXPORT']['Export_Id'])
436 self._manager._rm_export_file.assert_called_once_with(test_name)
437 self._manager._mkindex.assert_called_once_with()
438 self.assertEqual(None, ret)
439
440 def test_remove_export_error_during_read_export_file(self):
441 self.stubs.Set(self._manager, '_read_export_file',
442 mock.Mock(side_effect=exception.GaneshaCommandFailure))
443 methods = ('_remove_export_dbus', '_rm_export_file', '_mkindex')
444 for method in methods:
445 self.stubs.Set(self._manager, method, mock.Mock())
446 self.assertRaises(exception.GaneshaCommandFailure,
447 self._manager.remove_export, test_name)
448 self._manager._read_export_file.assert_called_once_with(test_name)
449 self.assertFalse(self._manager._remove_export_dbus.called)
450 self._manager._rm_export_file.assert_called_once_with(test_name)
451 self._manager._mkindex.assert_called_once_with()
452
453 def test_remove_export_error_during_remove_export_dbus(self):
454 self.stubs.Set(self._manager, '_read_export_file',
455 mock.Mock(return_value=test_dict_unicode))
456 self.stubs.Set(self._manager, '_remove_export_dbus',
457 mock.Mock(side_effect=exception.GaneshaCommandFailure))
458 methods = ('_rm_export_file', '_mkindex')
459 for method in methods:
460 self.stubs.Set(self._manager, method, mock.Mock())
461 self.assertRaises(exception.GaneshaCommandFailure,
462 self._manager.remove_export, test_name)
463 self._manager._read_export_file.assert_called_once_with(test_name)
464 self._manager._remove_export_dbus.assert_called_once_with(
465 test_dict_unicode['EXPORT']['Export_Id'])
466 self._manager._rm_export_file.assert_called_once_with(test_name)
467 self._manager._mkindex.assert_called_once_with()
468
469 def test_get_export_id(self):
470 self.stubs.Set(self._manager, 'execute',
471 mock.Mock(return_value=('exportid|101', '')))
472 ret = self._manager.get_export_id()
473 self._manager.execute.assert_called_once_with(
474 'sqlite3', self._manager.ganesha_db_path,
475 'update ganesha set value = value + 1;'
476 'select * from ganesha where key = "exportid";',
477 run_as_root=False)
478 self.assertEqual(101, ret)
479
480 def test_get_export_id_nobump(self):
481 self.stubs.Set(self._manager, 'execute',
482 mock.Mock(return_value=('exportid|101', '')))
483 ret = self._manager.get_export_id(bump=False)
484 self._manager.execute.assert_called_once_with(
485 'sqlite3', self._manager.ganesha_db_path,
486 'select * from ganesha where key = "exportid";',
487 run_as_root=False)
488 self.assertEqual(101, ret)
489
490 def test_get_export_id_error_invalid_export_db(self):
491 self.stubs.Set(self._manager, 'execute',
492 mock.Mock(return_value=('invalid', '')))
493 self.stubs.Set(manager.LOG, 'error', mock.Mock())
494 self.assertRaises(exception.InvalidSqliteDB,
495 self._manager.get_export_id)
496 manager.LOG.error.assert_called_once_with(
497 mock.ANY, mock.ANY)
498 self._manager.execute.assert_called_once_with(
499 'sqlite3', self._manager.ganesha_db_path,
500 'update ganesha set value = value + 1;'
501 'select * from ganesha where key = "exportid";',
502 run_as_root=False)
503
504 def test_restart_service(self):
505 self.stubs.Set(self._manager, 'execute', mock.Mock())
506 ret = self._manager.restart_service()
507 self._manager.execute.assert_called_once_with(
508 'service', 'ganesha.fakeservice', 'restart')
509 self.assertEqual(None, ret)
510
511 def test_reset_exports(self):
512 self.stubs.Set(self._manager, 'execute', mock.Mock())
513 self.stubs.Set(self._manager, '_mkindex', mock.Mock())
514 ret = self._manager.reset_exports()
515 self._manager.execute.assert_called_once_with(
516 'sh', '-c', 'rm /fakedir0/export.d/*.conf')
517 self._manager._mkindex.assert_called_once_with()
518 self.assertEqual(None, ret)
diff --git a/manila/tests/share/drivers/ganesha/test_utils.py b/manila/tests/share/drivers/ganesha/test_utils.py
new file mode 100644
index 0000000..2cd09bd
--- /dev/null
+++ b/manila/tests/share/drivers/ganesha/test_utils.py
@@ -0,0 +1,51 @@
1# Copyright (c) 2014 Red Hat, Inc.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import os
17
18from manila.share.drivers.ganesha import utils
19from manila import test
20
21
22patch_test_dict1 = {'a': 1, 'b': {'c': 2}, 'd': 3, 'e': 4}
23patch_test_dict2 = {'a': 11, 'b': {'f': 5}, 'd': {'g': 6}}
24patch_test_dict3 = {'b': {'c': 22, 'h': {'i': 7}}, 'e': None}
25patch_test_dict_result = {
26 'a': 11,
27 'b': {'c': 22, 'f': 5, 'h': {'i': 7}},
28 'd': {'g': 6},
29 'e': None,
30}
31
32walk_test_dict = {'a': {'b': {'c': {'d': {'e': 'f'}}}}}
33walk_test_list = [('e', 'f')]
34
35
36class GaneshaUtilsTests(test.TestCase):
37 """Tests Ganesha utility functions."""
38
39 def test_patch(self):
40 ret = utils.patch(patch_test_dict1, patch_test_dict2, patch_test_dict3)
41 self.assertEqual(patch_test_dict_result, ret)
42
43 def test_walk(self):
44 ret = [elem for elem in utils.walk(walk_test_dict)]
45 self.assertEqual(walk_test_list, ret)
46
47 def test_path_from(self):
48 self.stubs.Set(os.path, 'abspath',
49 lambda path: os.path.join('/foo/bar', path))
50 ret = utils.path_from('baz.py', '../quux', 'tic/tac/toe')
51 self.assertEqual('/foo/quux/tic/tac/toe', os.path.normpath(ret))
diff --git a/manila/tests/share/drivers/test_ganesha.py b/manila/tests/share/drivers/test_ganesha.py
new file mode 100644
index 0000000..5f4bc77
--- /dev/null
+++ b/manila/tests/share/drivers/test_ganesha.py
@@ -0,0 +1,276 @@
1# Copyright (c) 2014 Red Hat, Inc.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import copy
17import errno
18import os
19
20import mock
21from oslo.config import cfg
22
23from manila import exception
24from manila.share import configuration as config
25from manila.share.drivers import ganesha
26from manila import test
27from manila.tests.db import fakes as db_fakes
28
29CONF = cfg.CONF
30
31
32def fake_access(**kwargs):
33 access = {
34 'id': 'fakeaccessid',
35 'access_type': 'ip',
36 'access_to': '10.0.0.1'
37 }
38 access.update(kwargs)
39 return db_fakes.FakeModel(access)
40
41
42def fake_share(**kwargs):
43 share = {
44 'id': 'fakeid',
45 'name': 'fakename',
46 'size': 1,
47 'share_proto': 'NFS',
48 'export_location': '127.0.0.1:/mnt/nfs/testvol',
49 }
50 share.update(kwargs)
51 return db_fakes.FakeModel(share)
52
53fake_basepath = '/fakepath'
54
55fake_export_name = 'fakename--fakeaccessid'
56
57fake_output_template = {
58 'EXPORT': {
59 'Export_Id': 101,
60 'Path': '/fakepath/fakename',
61 'Pseudo': '/fakepath/fakename--fakeaccessid',
62 'Tag': 'fakeaccessid',
63 'CLIENT': {
64 'Clients': '10.0.0.1'
65 },
66 'FSAL': 'fakefsal'
67 }
68}
69
70
71class GaneshaNASHelperTestCase(test.TestCase):
72 """Tests GaneshaNASHElper."""
73
74 def setUp(self):
75 super(GaneshaNASHelperTestCase, self).setUp()
76
77 CONF.set_default('ganesha_config_path', '/fakedir0/fakeconfig')
78 CONF.set_default('ganesha_db_path', '/fakedir1/fake.db')
79 CONF.set_default('ganesha_export_dir', '/fakedir0/export.d')
80 CONF.set_default('ganesha_export_template_dir',
81 '/fakedir2/faketempl.d')
82 CONF.set_default('ganesha_service_name', 'ganesha.fakeservice')
83 self._execute = mock.Mock(return_value=('', ''))
84 self.fake_conf = config.Configuration(None)
85 self.fake_conf_dir_path = '/fakedir0/exports.d'
86 self._helper = ganesha.GaneshaNASHelper(
87 self._execute, self.fake_conf, tag='faketag')
88 self._helper.ganesha = mock.Mock()
89 self._helper.export_template = {'key': 'value'}
90 self.share = fake_share()
91 self.access = fake_access()
92
93 def test_load_conf_dir(self):
94 fake_template1 = {'key': 'value1'}
95 fake_template2 = {'key': 'value2'}
96 fake_ls_dir = ['fakefile0.conf', 'fakefile1.json', 'fakefile2.txt']
97 mock_ganesha_utils_patch = mock.Mock()
98
99 def fake_patch_run(tmpl1, tmpl2):
100 mock_ganesha_utils_patch(
101 copy.deepcopy(tmpl1), copy.deepcopy(tmpl2))
102 tmpl1.update(tmpl2)
103
104 self.stubs.Set(ganesha.os, 'listdir',
105 mock.Mock(return_value=fake_ls_dir))
106 self.stubs.Set(ganesha.LOG, 'info', mock.Mock())
107 self.stubs.Set(ganesha.ganesha_manager, 'parseconf',
108 mock.Mock(side_effect=[fake_template1,
109 fake_template2]))
110 self.stubs.Set(ganesha.ganesha_utils, 'patch',
111 mock.Mock(side_effect=fake_patch_run))
112 with mock.patch('six.moves.builtins.open',
113 mock.mock_open()) as mockopen:
114 mockopen().read.side_effect = ['fakeconf0', 'fakeconf1']
115 ret = self._helper._load_conf_dir(self.fake_conf_dir_path)
116 ganesha.os.listdir.assert_called_once_with(
117 self.fake_conf_dir_path)
118 ganesha.LOG.info.assert_called_once_with(
119 mock.ANY, self.fake_conf_dir_path)
120 mockopen.assert_has_calls([
121 mock.call('/fakedir0/exports.d/fakefile0.conf'),
122 mock.call('/fakedir0/exports.d/fakefile1.json')],
123 any_order=True)
124 ganesha.ganesha_manager.parseconf.assert_has_calls([
125 mock.call('fakeconf0'), mock.call('fakeconf1')])
126 mock_ganesha_utils_patch.assert_has_calls([
127 mock.call({}, fake_template1),
128 mock.call(fake_template1, fake_template2)])
129 self.assertEqual(fake_template2, ret)
130
131 def test_load_conf_dir_no_conf_dir_must_exist_false(self):
132 self.stubs.Set(
133 ganesha.os, 'listdir',
134 mock.Mock(side_effect=OSError(errno.ENOENT,
135 os.strerror(errno.ENOENT))))
136 self.stubs.Set(ganesha.LOG, 'info', mock.Mock())
137 self.stubs.Set(ganesha.ganesha_manager, 'parseconf', mock.Mock())
138 self.stubs.Set(ganesha.ganesha_utils, 'patch', mock.Mock())
139 with mock.patch('six.moves.builtins.open',
140 mock.mock_open(read_data='fakeconf')) as mockopen:
141 ret = self._helper._load_conf_dir(self.fake_conf_dir_path,
142 must_exist=False)
143 ganesha.os.listdir.assert_called_once_with(
144 self.fake_conf_dir_path)
145 ganesha.LOG.info.assert_called_once_with(
146 mock.ANY, self.fake_conf_dir_path)
147 self.assertFalse(mockopen.called)
148 self.assertFalse(ganesha.ganesha_manager.parseconf.called)
149 self.assertFalse(ganesha.ganesha_utils.patch.called)
150 self.assertEqual({}, ret)
151
152 def test_load_conf_dir_error_no_conf_dir_must_exist_true(self):
153 self.stubs.Set(
154 ganesha.os, 'listdir',
155 mock.Mock(side_effect=OSError(errno.ENOENT,
156 os.strerror(errno.ENOENT))))
157 self.assertRaises(OSError, self._helper._load_conf_dir,
158 self.fake_conf_dir_path)
159 ganesha.os.listdir.assert_called_once_with(self.fake_conf_dir_path)
160
161 def test_load_conf_dir_error_conf_dir_present_must_exist_false(self):
162 self.stubs.Set(
163 ganesha.os, 'listdir',
164 mock.Mock(side_effect=OSError(errno.EACCES,
165 os.strerror(errno.EACCES))))
166 self.assertRaises(OSError, self._helper._load_conf_dir,
167 self.fake_conf_dir_path, must_exist=False)
168 ganesha.os.listdir.assert_called_once_with(self.fake_conf_dir_path)
169
170 def test_load_conf_dir_error(self):
171 self.stubs.Set(
172 ganesha.os, 'listdir',
173 mock.Mock(side_effect=RuntimeError('fake error')))
174 self.assertRaises(RuntimeError, self._helper._load_conf_dir,
175 self.fake_conf_dir_path)
176 ganesha.os.listdir.assert_called_once_with(self.fake_conf_dir_path)
177
178 def test_init_helper(self):
179 mock_template = mock.Mock()
180 mock_ganesha_manager = mock.Mock()
181 self.stubs.Set(ganesha.ganesha_manager, 'GaneshaManager',
182 mock.Mock(return_value=mock_ganesha_manager))
183 self.stubs.Set(self._helper, '_load_conf_dir',
184 mock.Mock(return_value=mock_template))
185 self.stubs.Set(self._helper, '_default_config_hook', mock.Mock())
186 ret = self._helper.init_helper()
187 ganesha.ganesha_manager.GaneshaManager.assert_called_once_with(
188 self._execute, 'faketag',
189 ganesha_config_path='/fakedir0/fakeconfig',
190 ganesha_export_dir='/fakedir0/export.d',
191 ganesha_db_path='/fakedir1/fake.db',
192 ganesha_service_name='ganesha.fakeservice')
193 self._helper._load_conf_dir.assert_called_once_with(
194 '/fakedir2/faketempl.d', must_exist=False)
195 self.assertFalse(self._helper._default_config_hook.called)
196 self.assertEqual(mock_ganesha_manager, self._helper.ganesha)
197 self.assertEqual(mock_template, self._helper.export_template)
198 self.assertEqual(None, ret)
199
200 def test_init_helper_conf_dir_empty(self):
201 mock_template = mock.Mock()
202 mock_ganesha_manager = mock.Mock()
203 self.stubs.Set(ganesha.ganesha_manager, 'GaneshaManager',
204 mock.Mock(return_value=mock_ganesha_manager))
205 self.stubs.Set(self._helper, '_load_conf_dir',
206 mock.Mock(return_value={}))
207 self.stubs.Set(self._helper, '_default_config_hook',
208 mock.Mock(return_value=mock_template))
209 ret = self._helper.init_helper()
210 ganesha.ganesha_manager.GaneshaManager.assert_called_once_with(
211 self._execute, 'faketag',
212 ganesha_config_path='/fakedir0/fakeconfig',
213 ganesha_export_dir='/fakedir0/export.d',
214 ganesha_db_path='/fakedir1/fake.db',
215 ganesha_service_name='ganesha.fakeservice')
216 self._helper._load_conf_dir.assert_called_once_with(
217 '/fakedir2/faketempl.d', must_exist=False)
218 self._helper._default_config_hook.assert_called_once_with()
219 self.assertEqual(mock_ganesha_manager, self._helper.ganesha)
220 self.assertEqual(mock_template, self._helper.export_template)
221 self.assertEqual(None, ret)
222
223 def test_default_config_hook(self):
224 fake_template = {'key': 'value'}
225 self.stubs.Set(ganesha.ganesha_utils, 'path_from',
226 mock.Mock(return_value='/fakedir3/fakeconfdir'))
227 self.stubs.Set(self._helper, '_load_conf_dir',
228 mock.Mock(return_value=fake_template))
229 ret = self._helper._default_config_hook()
230 ganesha.ganesha_utils.path_from.assert_called_once_with(
231 ganesha.__file__, 'conf')
232 self._helper._load_conf_dir.assert_called_once_with(
233 '/fakedir3/fakeconfdir')
234 self.assertEqual(fake_template, ret)
235
236 def test_fsal_hook(self):
237 ret = self._helper._fsal_hook('/fakepath', self.share, self.access)
238 self.assertEqual({}, ret)
239
240 def test_allow_access(self):
241 mock_ganesha_utils_patch = mock.Mock()
242
243 def fake_patch_run(tmpl1, tmpl2, tmpl3):
244 mock_ganesha_utils_patch(copy.deepcopy(tmpl1), tmpl2, tmpl3)
245 tmpl1.update(tmpl3)
246
247 self.stubs.Set(self._helper.ganesha, 'get_export_id',
248 mock.Mock(return_value=101))
249 self.stubs.Set(self._helper, '_fsal_hook',
250 mock.Mock(return_value='fakefsal'))
251 self.stubs.Set(ganesha.ganesha_utils, 'patch',
252 mock.Mock(side_effect=fake_patch_run))
253 ret = self._helper.allow_access(fake_basepath, self.share,
254 self.access)
255 self._helper.ganesha.get_export_id.assert_called_once_with()
256 self._helper._fsal_hook.assert_called_once_with(
257 fake_basepath, self.share, self.access)
258 mock_ganesha_utils_patch.assert_called_once_with(
259 {}, self._helper.export_template, fake_output_template)
260 self._helper._fsal_hook.assert_called_once_with(
261 fake_basepath, self.share, self.access)
262 self._helper.ganesha.add_export.assert_called_once_with(
263 fake_export_name, fake_output_template)
264 self.assertEqual(None, ret)
265
266 def test_allow_access_error_invalid_share(self):
267 access = fake_access(access_type='notip')
268 self.assertRaises(exception.InvalidShareAccess,
269 self._helper.allow_access, '/fakepath',
270 self.share, access)
271
272 def test_deny_access(self):
273 ret = self._helper.deny_access('/fakepath', self.share, self.access)
274 self._helper.ganesha.remove_export.assert_called_once_with(
275 'fakename--fakeaccessid')
276 self.assertEqual(None, ret)