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

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 

19 

20from manila import exception 

21from manila.i18n import _ 

22from manila.share.drivers.zfssa import restclient 

23 

24 

25LOG = log.getLogger(__name__) 

26 

27 

28def factory_restclient(url, logfunc, **kwargs): 

29 return restclient.RestClientURL(url, logfunc, **kwargs) 

30 

31 

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' 

44 

45 def __init__(self): 

46 self.host = None 

47 self.url = None 

48 self.rclient = None 

49 

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 

53 

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 

66 

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']) 

74 

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) 

79 

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) 

84 

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) 

92 

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) 

105 

106 def get_pool_stats(self, pool): 

107 """Get space_available and used properties of a pool. 

108 

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 

133 

134 def get_project_stats(self, pool, project): 

135 """Get space_available of a project. 

136 

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 

156 

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) 

176 

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) 

181 

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 

187 

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) 

209 

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'] 

216 

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) 

227 

228 def delete_share(self, pool, project, share): 

229 """Delete a share. 

230 

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) 

248 

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) 

269 

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) 

290 

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) 

314 

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 

321 

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) 

327 

328 ip = access['access_to'] 

329 details = self.get_share(pool, project, share) 

330 sharenfs = details['sharenfs'] 

331 

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 

344 

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) 

350 

351 def deny_access_nfs(self, pool, project, share, access): 

352 """Denies access of an IP to a share through NFS. 

353 

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) 

363 

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 

375 

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) 

387 

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)