Coverage for manila/share/drivers/zfssa/zfssarest.py: 74%
207 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1# Copyright (c) 2014, Oracle and/or its affiliates. All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may
4# not use this file except in compliance with the License. You may obtain
5# a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations
13# under the License.
14"""
15ZFS Storage Appliance Proxy
16"""
17from oslo_log import log
18from oslo_serialization import jsonutils
20from manila import exception
21from manila.i18n import _
22from manila.share.drivers.zfssa import restclient
25LOG = log.getLogger(__name__)
28def factory_restclient(url, logfunc, **kwargs):
29 return restclient.RestClientURL(url, logfunc, **kwargs)
32class ZFSSAApi(object):
33 """ZFSSA API proxy class."""
34 pools_path = '/api/storage/v1/pools'
35 pool_path = pools_path + '/%s'
36 projects_path = pool_path + '/projects'
37 project_path = projects_path + '/%s'
38 shares_path = project_path + '/filesystems'
39 share_path = shares_path + '/%s'
40 snapshots_path = share_path + '/snapshots'
41 snapshot_path = snapshots_path + '/%s'
42 clone_path = snapshot_path + '/clone'
43 service_path = '/api/service/v1/services/%s/enable'
45 def __init__(self):
46 self.host = None
47 self.url = None
48 self.rclient = None
50 def __del__(self):
51 if self.rclient: 51 ↛ exitline 51 didn't return from function '__del__' because the condition on line 51 was always true
52 del self.rclient
54 def rest_get(self, path, expected):
55 ret = self.rclient.get(path)
56 if ret.status != expected:
57 exception_msg = (_('Rest call to %(host)s %(path)s failed.'
58 'Status: %(status)d Message: %(data)s')
59 % {'host': self.host,
60 'path': path,
61 'status': ret.status,
62 'data': ret.data})
63 LOG.error(exception_msg)
64 raise exception.ShareBackendException(msg=exception_msg)
65 return ret
67 def _is_pool_owned(self, pdata):
68 """returns True if the pool's owner is the same as the host."""
69 svc = '/api/system/v1/version'
70 ret = self.rest_get(svc, restclient.Status.OK)
71 vdata = jsonutils.loads(ret.data)
72 return (vdata['version']['asn'] == pdata['pool']['asn'] and
73 vdata['version']['nodename'] == pdata['pool']['owner'])
75 def set_host(self, host, timeout=None):
76 self.host = host
77 self.url = "https://%s:215" % self.host
78 self.rclient = factory_restclient(self.url, LOG.debug, timeout=timeout)
80 def login(self, auth_str):
81 """Login to the appliance."""
82 if self.rclient and not self.rclient.islogin():
83 self.rclient.login(auth_str)
85 def enable_service(self, service):
86 """Enable the specified service."""
87 svc = self.service_path % service
88 ret = self.rclient.put(svc)
89 if ret.status != restclient.Status.ACCEPTED:
90 exception_msg = (_("Cannot enable %s service.") % service)
91 raise exception.ShareBackendException(msg=exception_msg)
93 def verify_avail_space(self, pool, project, share, size):
94 """Check if there is enough space available to a new share."""
95 self.verify_project(pool, project)
96 avail = self.get_project_stats(pool, project)
97 if avail < size:
98 exception_msg = (_('Error creating '
99 'share: %(share)s on '
100 'pool: %(pool)s. '
101 'Not enough space.')
102 % {'share': share,
103 'pool': pool})
104 raise exception.ShareBackendException(msg=exception_msg)
106 def get_pool_stats(self, pool):
107 """Get space_available and used properties of a pool.
109 returns (avail, used).
110 """
111 svc = self.pool_path % pool
112 ret = self.rclient.get(svc)
113 if ret.status != restclient.Status.OK:
114 exception_msg = (_('Error getting pool stats: '
115 'pool: %(pool)s '
116 'return code: %(ret.status)d '
117 'message: %(ret.data)s.')
118 % {'pool': pool,
119 'ret.status': ret.status,
120 'ret.data': ret.data})
121 raise exception.InvalidInput(reason=exception_msg)
122 val = jsonutils.loads(ret.data)
123 if not self._is_pool_owned(val):
124 exception_msg = (_('Error pool ownership: '
125 'pool %(pool)s is not owned '
126 'by %(host)s.')
127 % {'pool': pool,
128 'host': self.host})
129 raise exception.InvalidInput(reason=pool)
130 avail = val['pool']['usage']['available']
131 used = val['pool']['usage']['used']
132 return avail, used
134 def get_project_stats(self, pool, project):
135 """Get space_available of a project.
137 Used to check whether a project has enough space (after reservation)
138 or not.
139 """
140 svc = self.project_path % (pool, project)
141 ret = self.rclient.get(svc)
142 if ret.status != restclient.Status.OK:
143 exception_msg = (_('Error getting project stats: '
144 'pool: %(pool)s '
145 'project: %(project)s '
146 'return code: %(ret.status)d '
147 'message: %(ret.data)s.')
148 % {'pool': pool,
149 'project': project,
150 'ret.status': ret.status,
151 'ret.data': ret.data})
152 raise exception.InvalidInput(reason=exception_msg)
153 val = jsonutils.loads(ret.data)
154 avail = val['project']['space_available']
155 return avail
157 def create_project(self, pool, project, arg):
158 """Create a project on a pool. Check first whether the pool exists."""
159 self.verify_pool(pool)
160 svc = self.project_path % (pool, project)
161 ret = self.rclient.get(svc)
162 if ret.status != restclient.Status.OK: 162 ↛ exitline 162 didn't return from function 'create_project' because the condition on line 162 was always true
163 svc = self.projects_path % pool
164 ret = self.rclient.post(svc, arg)
165 if ret.status != restclient.Status.CREATED:
166 exception_msg = (_('Error creating project: '
167 '%(project)s on '
168 'pool: %(pool)s '
169 'return code: %(ret.status)d '
170 'message: %(ret.data)s.')
171 % {'project': project,
172 'pool': pool,
173 'ret.status': ret.status,
174 'ret.data': ret.data})
175 raise exception.ShareBackendException(msg=exception_msg)
177 def verify_pool(self, pool):
178 """Checks whether pool exists."""
179 svc = self.pool_path % pool
180 self.rest_get(svc, restclient.Status.OK)
182 def verify_project(self, pool, project):
183 """Checks whether project exists."""
184 svc = self.project_path % (pool, project)
185 ret = self.rest_get(svc, restclient.Status.OK)
186 return ret
188 def create_share(self, pool, project, share):
189 """Create a share in the specified pool and project."""
190 self.verify_avail_space(pool, project, share, share['quota'])
191 svc = self.share_path % (pool, project, share['name'])
192 ret = self.rclient.get(svc)
193 if ret.status != restclient.Status.OK:
194 svc = self.shares_path % (pool, project)
195 ret = self.rclient.post(svc, share)
196 if ret.status != restclient.Status.CREATED:
197 exception_msg = (_('Error creating '
198 'share: %(name)s '
199 'return code: %(ret.status)d '
200 'message: %(ret.data)s.')
201 % {'name': share['name'],
202 'ret.status': ret.status,
203 'ret.data': ret.data})
204 raise exception.ShareBackendException(msg=exception_msg)
205 else:
206 exception_msg = (_('Share with name %s already exists.')
207 % share['name'])
208 raise exception.ShareBackendException(msg=exception_msg)
210 def get_share(self, pool, project, share):
211 """Return share properties."""
212 svc = self.share_path % (pool, project, share)
213 ret = self.rest_get(svc, restclient.Status.OK)
214 val = jsonutils.loads(ret.data)
215 return val['filesystem']
217 def modify_share(self, pool, project, share, arg):
218 """Modify a set of properties of a share."""
219 svc = self.share_path % (pool, project, share)
220 ret = self.rclient.put(svc, arg)
221 if ret.status != restclient.Status.ACCEPTED:
222 exception_msg = (_('Error modifying %(arg)s '
223 ' of share %(id)s.')
224 % {'arg': arg,
225 'id': share})
226 raise exception.ShareBackendException(msg=exception_msg)
228 def delete_share(self, pool, project, share):
229 """Delete a share.
231 The function assumes the share has no clone or snapshot.
232 """
233 svc = self.share_path % (pool, project, share)
234 ret = self.rclient.delete(svc)
235 if ret.status != restclient.Status.NO_CONTENT: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 exception_msg = (('Error deleting '
237 'share: %(share)s to '
238 'pool: %(pool)s '
239 'project: %(project)s '
240 'return code: %(ret.status)d '
241 'message: %(ret.data)s.'),
242 {'share': share,
243 'pool': pool,
244 'project': project,
245 'ret.status': ret.status,
246 'ret.data': ret.data})
247 LOG.error(exception_msg)
249 def create_snapshot(self, pool, project, share, snapshot):
250 """Create a snapshot of the given share."""
251 svc = self.snapshots_path % (pool, project, share)
252 arg = {'name': snapshot}
253 ret = self.rclient.post(svc, arg)
254 if ret.status != restclient.Status.CREATED:
255 exception_msg = (_('Error creating '
256 'snapshot: %(snapshot)s on '
257 'share: %(share)s to '
258 'pool: %(pool)s '
259 'project: %(project)s '
260 'return code: %(ret.status)d '
261 'message: %(ret.data)s.')
262 % {'snapshot': snapshot,
263 'share': share,
264 'pool': pool,
265 'project': project,
266 'ret.status': ret.status,
267 'ret.data': ret.data})
268 raise exception.ShareBackendException(msg=exception_msg)
270 def delete_snapshot(self, pool, project, share, snapshot):
271 """Delete a snapshot that has no clone."""
272 svc = self.snapshot_path % (pool, project, share, snapshot)
273 ret = self.rclient.delete(svc)
274 if ret.status != restclient.Status.NO_CONTENT:
275 exception_msg = (_('Error deleting '
276 'snapshot: %(snapshot)s on '
277 'share: %(share)s to '
278 'pool: %(pool)s '
279 'project: %(project)s '
280 'return code: %(ret.status)d '
281 'message: %(ret.data)s.')
282 % {'snapshot': snapshot,
283 'share': share,
284 'pool': pool,
285 'project': project,
286 'ret.status': ret.status,
287 'ret.data': ret.data})
288 LOG.error(exception_msg)
289 raise exception.ShareBackendException(msg=exception_msg)
291 def clone_snapshot(self, pool, project, snapshot, clone, arg):
292 """Create a new share from the given snapshot."""
293 self.verify_avail_space(pool, project, clone['id'], clone['size'])
294 svc = self.clone_path % (pool, project,
295 snapshot['share_id'],
296 snapshot['id'])
297 ret = self.rclient.put(svc, arg)
298 if ret.status != restclient.Status.CREATED:
299 exception_msg = (_('Error cloning '
300 'snapshot: %(snapshot)s on '
301 'share: %(share)s of '
302 'Pool: %(pool)s '
303 'project: %(project)s '
304 'return code: %(ret.status)d '
305 'message: %(ret.data)s.')
306 % {'snapshot': snapshot['id'],
307 'share': snapshot['share_id'],
308 'pool': pool,
309 'project': project,
310 'ret.status': ret.status,
311 'ret.data': ret.data})
312 LOG.error(exception_msg)
313 raise exception.ShareBackendException(msg=exception_msg)
315 def has_clones(self, pool, project, share, snapshot):
316 """Check whether snapshot has existing clones."""
317 svc = self.snapshot_path % (pool, project, share, snapshot)
318 ret = self.rest_get(svc, restclient.Status.OK)
319 val = jsonutils.loads(ret.data)
320 return val['snapshot']['numclones'] != 0
322 def allow_access_nfs(self, pool, project, share, access):
323 """Allow an IP access to a share through NFS."""
324 if access['access_type'] != 'ip':
325 reason = _('Only ip access type allowed.')
326 raise exception.InvalidShareAccess(reason)
328 ip = access['access_to']
329 details = self.get_share(pool, project, share)
330 sharenfs = details['sharenfs']
332 if sharenfs == 'on' or sharenfs == 'rw':
333 LOG.debug('Share %s has read/write permission '
334 'open to all.', share)
335 return
336 if sharenfs == 'off':
337 sharenfs = 'sec=sys'
338 if ip in sharenfs:
339 LOG.debug('Access to share %(share)s via NFS '
340 'already granted to %(ip)s.',
341 {'share': share,
342 'ip': ip})
343 return
345 entry = (',rw=@%s' % ip)
346 if '/' not in ip:
347 entry = "%s/32" % entry
348 arg = {'sharenfs': sharenfs + entry}
349 self.modify_share(pool, project, share, arg)
351 def deny_access_nfs(self, pool, project, share, access):
352 """Denies access of an IP to a share through NFS.
354 Since sharenfs property allows a combination of mutiple syntaxes:
355 sharenfs="sec=sys,rw=@first_ip,rw=@second_ip"
356 sharenfs="sec=sys,rw=@first_ip:@second_ip"
357 sharenfs="sec=sys,rw=@first_ip:@second_ip,rw=@third_ip"
358 The function checks what syntax is used and remove the IP accordingly.
359 """
360 if access['access_type'] != 'ip':
361 reason = _('Only ip access type allowed.')
362 raise exception.InvalidShareAccess(reason)
364 ip = access['access_to']
365 entry = ('@%s' % ip)
366 if '/' not in ip: 366 ↛ 368line 366 didn't jump to line 368 because the condition on line 366 was always true
367 entry = "%s/32" % entry
368 details = self.get_share(pool, project, share)
369 if entry not in details['sharenfs']:
370 LOG.debug('IP %(ip)s does not have access '
371 'to Share %(share)s via NFS.',
372 {'ip': ip,
373 'share': share})
374 return
376 sharenfs = str(details['sharenfs'])
377 argval = ''
378 if sharenfs.find((',rw=%s:' % entry)) >= 0: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true
379 argval = sharenfs.replace(('%s:' % entry), '')
380 elif sharenfs.find((',rw=%s' % entry)) >= 0: 380 ↛ 382line 380 didn't jump to line 382 because the condition on line 380 was always true
381 argval = sharenfs.replace((',rw=%s' % entry), '')
382 elif sharenfs.find((':%s' % entry)) >= 0:
383 argval = sharenfs.replace((':%s' % entry), '')
384 arg = {'sharenfs': argval}
385 LOG.debug('deny_access: %s', argval)
386 self.modify_share(pool, project, share, arg)
388 def create_schema(self, schema):
389 """Create a custom ZFSSA schema."""
390 base = '/api/storage/v1/schema'
391 svc = "%(base)s/%(prop)s" % {'base': base, 'prop': schema['property']}
392 ret = self.rclient.get(svc)
393 if ret.status == restclient.Status.OK:
394 LOG.warning('Property %s already exists.', schema['property'])
395 return
396 ret = self.rclient.post(base, schema)
397 if ret.status != restclient.Status.CREATED:
398 exception_msg = (_('Error Creating '
399 'Property: %(property)s '
400 'Type: %(type)s '
401 'Description: %(description)s '
402 'Return code: %(ret.status)d '
403 'Message: %(ret.data)s.')
404 % {'property': schema['property'],
405 'type': schema['type'],
406 'description': schema['description'],
407 'ret.status': ret.status,
408 'ret.data': ret.data})
409 LOG.error(exception_msg)
410 raise exception.ShareBackendException(msg=exception_msg)