Coverage for manila/share/drivers/inspur/instorage/cli_helper.py: 89%

250 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-02-18 22:19 +0000

1# Copyright 2019 Inspur Corp. 

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 

16""" 

17CLI helpers for Inspur InStorage 

18""" 

19 

20import paramiko 

21import re 

22import time 

23 

24from oslo_concurrency import processutils 

25from oslo_log import log 

26from oslo_utils import excutils 

27 

28from manila import exception 

29from manila.i18n import _ 

30from manila import ssh_utils 

31from manila import utils as manila_utils 

32 

33LOG = log.getLogger(__name__) 

34 

35 

36class SSHRunner(object): 

37 """SSH runner is used to run ssh command on inspur instorage system.""" 

38 

39 def __init__(self, host, port, login, password, privatekey=None): 

40 self.host = host 

41 self.port = port 

42 self.login = login 

43 self.password = password 

44 self.privatekey = privatekey 

45 

46 self.ssh_conn_timeout = 60 

47 self.ssh_min_pool_size = 1 

48 self.ssh_max_pool_size = 10 

49 

50 self.sshpool = None 

51 

52 def __call__(self, cmd_list, check_exit_code=True, attempts=1): 

53 """SSH tool""" 

54 manila_utils.check_ssh_injection(cmd_list) 

55 command = ' '.join(cmd_list) 

56 if not self.sshpool: 56 ↛ 71line 56 didn't jump to line 71 because the condition on line 56 was always true

57 try: 

58 self.sshpool = ssh_utils.SSHPool( 

59 self.host, 

60 self.port, 

61 self.ssh_conn_timeout, 

62 self.login, 

63 password=self.password, 

64 privatekey=self.privatekey, 

65 min_size=self.ssh_min_pool_size, 

66 max_size=self.ssh_max_pool_size 

67 ) 

68 except paramiko.SSHException: 

69 LOG.error("Unable to create SSHPool") 

70 raise 

71 try: 

72 return self._ssh_execute(self.sshpool, command, 

73 check_exit_code, attempts) 

74 except Exception: 

75 LOG.error("Error running SSH command: %s", command) 

76 raise 

77 

78 def _ssh_execute(self, sshpool, command, 

79 check_exit_code=True, attempts=1): 

80 try: 

81 with sshpool.item() as ssh: 

82 last_exception = None 

83 while attempts > 0: 

84 attempts -= 1 

85 try: 

86 return processutils.ssh_execute( 

87 ssh, 

88 command, 

89 check_exit_code=check_exit_code) 

90 except Exception as e: 

91 LOG.exception('Error has occurred') 

92 last_exception = e 

93 time.sleep(1) 

94 

95 try: 

96 raise processutils.ProcessExecutionError( 

97 exit_code=last_exception.exit_code, 

98 stdout=last_exception.stdout, 

99 stderr=last_exception.stderr, 

100 cmd=last_exception.cmd) 

101 except AttributeError: 

102 raise processutils.ProcessExecutionError( 

103 exit_code=-1, 

104 stdout="", 

105 stderr="Error running SSH command", 

106 cmd=command) 

107 

108 except Exception: 

109 with excutils.save_and_reraise_exception(): 

110 LOG.error("Error running SSH command: %s", command) 

111 

112 

113class CLIParser(object): 

114 """Parse MCS CLI output and generate iterable.""" 

115 

116 def __init__(self, raw, ssh_cmd=None, delim='!', with_header=True): 

117 super(CLIParser, self).__init__() 

118 if ssh_cmd: 118 ↛ 121line 118 didn't jump to line 121 because the condition on line 118 was always true

119 self.ssh_cmd = ' '.join(ssh_cmd) 

120 else: 

121 self.ssh_cmd = 'None' 

122 self.raw = raw 

123 self.delim = delim 

124 self.with_header = with_header 

125 self.result = self._parse() 

126 

127 def __getitem__(self, key): 

128 try: 

129 return self.result[key] 

130 except KeyError: 

131 msg = (_('Did not find the expected key %(key)s in %(fun)s: ' 

132 '%(raw)s.') % {'key': key, 'fun': self.ssh_cmd, 

133 'raw': self.raw}) 

134 raise exception.ShareBackendException(msg=msg) 

135 

136 def __iter__(self): 

137 for a in self.result: 

138 yield a 

139 

140 def __len__(self): 

141 return len(self.result) 

142 

143 def _parse(self): 

144 def get_reader(content, delim): 

145 for line in content.lstrip().splitlines(): 

146 line = line.strip() 

147 if line: 

148 yield line.split(delim) 

149 else: 

150 yield [] 

151 

152 if isinstance(self.raw, str): 

153 stdout, stderr = self.raw, '' 

154 else: 

155 stdout, stderr = self.raw 

156 reader = get_reader(stdout, self.delim) 

157 result = [] 

158 

159 if self.with_header: 

160 hds = tuple() 

161 for row in reader: 

162 hds = row 

163 break 

164 for row in reader: 

165 cur = dict() 

166 if len(hds) != len(row): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 msg = (_('Unexpected CLI response: header/row mismatch. ' 

168 'header: %(header)s, row: %(row)s.') 

169 % {'header': hds, 

170 'row': row}) 

171 raise exception.ShareBackendException(msg=msg) 

172 for k, v in zip(hds, row): 

173 CLIParser.append_dict(cur, k, v) 

174 result.append(cur) 

175 else: 

176 cur = dict() 

177 for row in reader: 

178 if row: 

179 CLIParser.append_dict(cur, row[0], ' '.join(row[1:])) 

180 elif cur: # start new section 180 ↛ 177line 180 didn't jump to line 177 because the condition on line 180 was always true

181 result.append(cur) 

182 cur = dict() 

183 if cur: 183 ↛ 185line 183 didn't jump to line 185 because the condition on line 183 was always true

184 result.append(cur) 

185 return result 

186 

187 @staticmethod 

188 def append_dict(dict_, key, value): 

189 key, value = key.strip(), value.strip() 

190 obj = dict_.get(key, None) 

191 if obj is None: 191 ↛ 193line 191 didn't jump to line 193 because the condition on line 191 was always true

192 dict_[key] = value 

193 elif isinstance(obj, list): 

194 obj.append(value) 

195 dict_[key] = obj 

196 else: 

197 dict_[key] = [obj, value] 

198 return dict_ 

199 

200 

201class InStorageSSH(object): 

202 """SSH interface to Inspur InStorage systems.""" 

203 

204 def __init__(self, ssh_runner): 

205 self._ssh = ssh_runner 

206 

207 def _run_ssh(self, ssh_cmd): 

208 try: 

209 return self._ssh(ssh_cmd) 

210 except processutils.ProcessExecutionError as e: 

211 msg = (_('CLI Exception output:\n command: %(cmd)s\n ' 

212 'stdout: %(out)s\n stderr: %(err)s.') % 

213 {'cmd': ssh_cmd, 

214 'out': e.stdout, 

215 'err': e.stderr}) 

216 LOG.error(msg) 

217 raise exception.ShareBackendException(msg=msg) 

218 

219 def run_ssh_inq(self, ssh_cmd, delim='!', with_header=False): 

220 """Run an SSH command and return parsed output.""" 

221 raw = self._run_ssh(ssh_cmd) 

222 LOG.debug('Response for cmd %s is %s', ssh_cmd, raw) 

223 return CLIParser(raw, ssh_cmd=ssh_cmd, delim=delim, 

224 with_header=with_header) 

225 

226 def run_ssh_assert_no_output(self, ssh_cmd): 

227 """Run an SSH command and assert no output returned.""" 

228 out, err = self._run_ssh(ssh_cmd) 

229 if len(out.strip()) != 0: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true

230 msg = (_('Expected no output from CLI command %(cmd)s, ' 

231 'got %(out)s.') % {'cmd': ' '.join(ssh_cmd), 'out': out}) 

232 LOG.error(msg) 

233 raise exception.ShareBackendException(msg=msg) 

234 

235 def run_ssh_check_created(self, ssh_cmd): 

236 """Run an SSH command and return the ID of the created object.""" 

237 out, err = self._run_ssh(ssh_cmd) 

238 try: 

239 match_obj = re.search(r'\[([0-9]+)\],? successfully created', out) 

240 return match_obj.group(1) 

241 except (AttributeError, IndexError): 

242 msg = (_('Failed to parse CLI output:\n command: %(cmd)s\n ' 

243 'stdout: %(out)s\n stderr: %(err)s.') % 

244 {'cmd': ssh_cmd, 

245 'out': out, 

246 'err': err}) 

247 LOG.error(msg) 

248 raise exception.ShareBackendException(msg=msg) 

249 

250 def lsnode(self, node_id=None): 

251 with_header = True 

252 ssh_cmd = ['mcsinq', 'lsnode', '-delim', '!'] 

253 if node_id: 

254 with_header = False 

255 ssh_cmd.append(node_id) 

256 return self.run_ssh_inq(ssh_cmd, with_header=with_header) 

257 

258 def lsnaspool(self, pool_id=None): 

259 ssh_cmd = ['mcsinq', 'lsnaspool', '-delim', '!'] 

260 if pool_id: 

261 ssh_cmd.append(pool_id) 

262 return self.run_ssh_inq(ssh_cmd, with_header=True) 

263 

264 def lsfs(self, node_name=None, fsname=None): 

265 if fsname and not node_name: 

266 msg = _('Node name should be set when file system name is set.') 

267 LOG.error(msg) 

268 raise exception.InvalidParameterValue(msg) 

269 

270 ssh_cmd = ['mcsinq', 'lsfs', '-delim', '!'] 

271 to_append = [] 

272 

273 if node_name: 

274 to_append += ['-node', '"%s"' % node_name] 

275 

276 if fsname: 

277 to_append += ['-name', '"%s"' % fsname] 

278 

279 if not to_append: 

280 to_append += ['-all'] 

281 

282 ssh_cmd += to_append 

283 return self.run_ssh_inq(ssh_cmd, with_header=True) 

284 

285 def addfs(self, fsname, pool_name, size, node_name): 

286 """Create a file system on the storage. 

287 

288 :param fsname: file system name 

289 :param pool_name: pool in which to create the file system 

290 :param size: file system size in GB 

291 :param node_name: the primary node name 

292 :return: 

293 """ 

294 

295 ssh_cmd = ['mcsop', 'addfs', '-name', '"%s"' % fsname, '-pool', 

296 '"%s"' % pool_name, '-size', '%dg' % size, 

297 '-node', '"%s"' % node_name] 

298 self.run_ssh_assert_no_output(ssh_cmd) 

299 

300 def rmfs(self, fsname): 

301 """Remove the specific file system. 

302 

303 :param fsname: file system name to be removed 

304 :return: 

305 """ 

306 

307 ssh_cmd = ['mcsop', 'rmfs', '-name', '"%s"' % fsname] 

308 self.run_ssh_assert_no_output(ssh_cmd) 

309 

310 def expandfs(self, fsname, size): 

311 """Expand the space of the specific file system. 

312 

313 :param fsname: file system name 

314 :param size: the size(GB) to be expanded, origin + size = result 

315 :return: 

316 """ 

317 

318 ssh_cmd = ['mcsop', 'expandfs', '-name', '"%s"' % fsname, 

319 '-size', '%dg' % size] 

320 self.run_ssh_assert_no_output(ssh_cmd) 

321 

322 # NAS directory operation 

323 def lsnasdir(self, dirpath): 

324 """List the child directory under dirpath. 

325 

326 :param dirpath: the parent directory to list with 

327 :return: 

328 """ 

329 

330 ssh_cmd = ['mcsinq', 'lsnasdir', '-delim', '!', '"%s"' % dirpath] 

331 return self.run_ssh_inq(ssh_cmd, with_header=True) 

332 

333 def addnasdir(self, dirpath): 

334 """Create a new NAS directory indicated by dirpath.""" 

335 

336 ssh_cmd = ['mcsop', 'addnasdir', '"%s"' % dirpath] 

337 self.run_ssh_assert_no_output(ssh_cmd) 

338 

339 def chnasdir(self, old_path, new_path): 

340 """Rename the NAS directory name.""" 

341 

342 ssh_cmd = ['mcsop', 'chnasdir', '-oldpath', '"%s"' % old_path, 

343 '-newpath', '"%s"' % new_path] 

344 self.run_ssh_assert_no_output(ssh_cmd) 

345 

346 def rmnasdir(self, dirpath): 

347 """Remove the specific dirpath.""" 

348 

349 ssh_cmd = ['mcsop', 'rmnasdir', '"%s"' % dirpath] 

350 self.run_ssh_assert_no_output(ssh_cmd) 

351 

352 # NFS operation 

353 def rmnfs(self, share_path): 

354 """Remove the NFS indicated by path.""" 

355 

356 ssh_cmd = ['mcsop', 'rmnfs', '"%s"' % share_path] 

357 self.run_ssh_assert_no_output(ssh_cmd) 

358 

359 def lsnfslist(self, prefix=None): 

360 """List NFS shares on a system.""" 

361 

362 ssh_cmd = ['mcsinq', 'lsnfslist', '-delim', '!'] 

363 if prefix: 

364 ssh_cmd.append('"%s"' % prefix) 

365 

366 return self.run_ssh_inq(ssh_cmd, with_header=True) 

367 

368 def lsnfsinfo(self, share_path): 

369 """List a specific NFS share's information.""" 

370 

371 ssh_cmd = ['mcsinq', 'lsnfsinfo', '-delim', '!', '"%s"' % share_path] 

372 return self.run_ssh_inq(ssh_cmd, with_header=True) 

373 

374 def addnfsclient(self, share_path, client_spec): 

375 """Add a client access rule to NFS share. 

376 

377 :param share_path: the NFS share path. 

378 :param client_spec: IP/MASK:RIGHTS:ALL_SQUASH:ROOT_SQUASH. 

379 :return: 

380 """ 

381 

382 ssh_cmd = ['mcsop', 'addnfsclient', '-path', '"%s"' % share_path, 

383 '-client', client_spec] 

384 self.run_ssh_assert_no_output(ssh_cmd) 

385 

386 def chnfsclient(self, share_path, client_spec): 

387 """Change a NFS share's client info.""" 

388 

389 ssh_cmd = ['mcsop', 'chnfsclient', '-path', '"%s"' % share_path, 

390 '-client', client_spec] 

391 self.run_ssh_assert_no_output(ssh_cmd) 

392 

393 def rmnfsclient(self, share_path, client_spec): 

394 """Remove a client info from the NFS share.""" 

395 

396 # client_spec parameter for rmnfsclient is IP/MASK, 

397 # so we need remove the right part 

398 client_spec = client_spec.split(':')[0] 

399 

400 ssh_cmd = ['mcsop', 'rmnfsclient', '-path', '"%s"' % share_path, 

401 '-client', client_spec] 

402 self.run_ssh_assert_no_output(ssh_cmd) 

403 

404 # CIFS operation 

405 def lscifslist(self, filter=None): 

406 """List CIFS shares on the system.""" 

407 

408 ssh_cmd = ['mcsinq', 'lscifslist', '-delim', '!'] 

409 if filter: 

410 ssh_cmd.append('"%s"' % filter) 

411 

412 return self.run_ssh_inq(ssh_cmd, with_header=True) 

413 

414 def lscifsinfo(self, share_name): 

415 """List a specific CIFS share's information.""" 

416 

417 ssh_cmd = ['mcsinq', 'lscifsinfo', '-delim', '!', '"%s"' % share_name] 

418 return self.run_ssh_inq(ssh_cmd, with_header=True) 

419 

420 def addcifs(self, share_name, dirpath, oplocks='off'): 

421 """Create a CIFS share with given path.""" 

422 ssh_cmd = ['mcsop', 'addcifs', '-name', share_name, '-path', dirpath, 

423 '-oplocks', oplocks] 

424 self.run_ssh_assert_no_output(ssh_cmd) 

425 

426 def rmcifs(self, share_name): 

427 """Remove a CIFS share.""" 

428 

429 ssh_cmd = ['mcsop', 'rmcifs', share_name] 

430 self.run_ssh_assert_no_output(ssh_cmd) 

431 

432 def chcifs(self, share_name, oplocks='off'): 

433 """Change a CIFS share's attribute. 

434 

435 :param share_name: share's name 

436 :param oplocks: 'off' or 'on' 

437 :return: 

438 """ 

439 ssh_cmd = ['mcsop', 'chcifs', '-name', share_name, '-oplocks', oplocks] 

440 self.run_ssh_assert_no_output(ssh_cmd) 

441 

442 def addcifsuser(self, share_name, rights): 

443 """Add a user access rule to CIFS share. 

444 

445 :param share_name: share's name 

446 :param rights: [LU|LG]:xxx:[rw|ro] 

447 :return: 

448 """ 

449 ssh_cmd = ['mcsop', 'addcifsuser', '-name', share_name, 

450 '-rights', rights] 

451 self.run_ssh_assert_no_output(ssh_cmd) 

452 

453 def chcifsuser(self, share_name, rights): 

454 """Change a user access rule.""" 

455 

456 ssh_cmd = ['mcsop', 'chcifsuser', '-name', share_name, 

457 '-rights', rights] 

458 self.run_ssh_assert_no_output(ssh_cmd) 

459 

460 def rmcifsuser(self, share_name, rights): 

461 """Remove CIFS user from a CIFS share.""" 

462 

463 # the rights parameter for rmcifsuser is LU:NAME 

464 rights = ':'.join(rights.split(':')[0:-1]) 

465 

466 ssh_cmd = ['mcsop', 'rmcifsuser', '-name', share_name, 

467 '-rights', rights] 

468 self.run_ssh_assert_no_output(ssh_cmd) 

469 

470 # NAS port ip 

471 def lsnasportip(self): 

472 """List NAS service port ip address.""" 

473 

474 ssh_cmd = ['mcsinq', 'lsnasportip', '-delim', '!'] 

475 return self.run_ssh_inq(ssh_cmd, with_header=True)