Coverage for manila/cmd/manage.py: 78%

313 statements  

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

1#!/usr/bin/env python3 

2 

3# Copyright (c) 2011 X.commerce, a business unit of eBay Inc. 

4# Copyright 2010 United States Government as represented by the 

5# Administrator of the National Aeronautics and Space Administration. 

6# All Rights Reserved. 

7# 

8# Licensed under the Apache License, Version 2.0 (the "License"); you may 

9# not use this file except in compliance with the License. You may obtain 

10# a copy of the License at 

11# 

12# http://www.apache.org/licenses/LICENSE-2.0 

13# 

14# Unless required by applicable law or agreed to in writing, software 

15# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 

16# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 

17# License for the specific language governing permissions and limitations 

18# under the License. 

19 

20# Interactive shell based on Django: 

21# 

22# Copyright (c) 2005, the Lawrence Journal-World 

23# All rights reserved. 

24# 

25# Redistribution and use in source and binary forms, with or without 

26# modification, are permitted provided that the following conditions are met: 

27# 

28# 1. Redistributions of source code must retain the above copyright notice, 

29# this list of conditions and the following disclaimer. 

30# 

31# 2. Redistributions in binary form must reproduce the above copyright 

32# notice, this list of conditions and the following disclaimer in the 

33# documentation and/or other materials provided with the distribution. 

34# 

35# 3. Neither the name of Django nor the names of its contributors may be 

36# used to endorse or promote products derived from this software without 

37# specific prior written permission. 

38# 

39# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 

40# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 

41# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 

42# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 

43# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 

44# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 

45# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 

46# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 

47# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 

48# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 

49# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 

50 

51 

52""" 

53 CLI interface for manila management. 

54""" 

55 

56import os 

57import sys 

58import yaml 

59 

60from oslo_config import cfg 

61from oslo_log import log 

62from oslo_serialization import jsonutils 

63 

64from manila.common import config # Need to register global_opts # noqa 

65from manila import context 

66from manila import db 

67from manila.db import migration 

68from manila.i18n import _ 

69from manila import utils 

70from manila import version 

71 

72CONF = cfg.CONF 

73 

74ALLOWED_OUTPUT_FORMATS = ['table', 'json', 'yaml'] 

75HOST_UPDATE_HELP_MSG = ("A fully qualified host string is of the format " 

76 "'HostA@BackendB#PoolC'. Provide only the host name " 

77 "(ex: 'HostA') to update the hostname part of " 

78 "the host string. Provide only the " 

79 "host name and backend name (ex: 'HostA@BackendB') to " 

80 "update the host and backend names.") 

81HOST_UPDATE_CURRENT_HOST_HELP = ("Current share host name. %s" % 

82 HOST_UPDATE_HELP_MSG) 

83HOST_UPDATE_NEW_HOST_HELP = "New share host name. %s" % HOST_UPDATE_HELP_MSG 

84LIST_OUTPUT_FORMAT_HELP = ("Format to be used to print the output (table, " 

85 "json, yaml). Defaults to 'table'") 

86SHARE_SERVERS_UPDATE_HELP = ("List of share servers to be updated, separated " 

87 "by commas.") 

88SHARE_SERVERS_UPDATE_CAPABILITIES_HELP = ( 

89 "List of share server capabilities to be updated, separated by commas.") 

90SHARE_DELETE_HELP = ("Share ID to be deleted.") 

91 

92 

93# Decorators for actions 

94def args(*args, **kwargs): 

95 def _decorator(func): 

96 func.__dict__.setdefault('args', []).insert(0, (args, kwargs)) 

97 return func 

98 return _decorator 

99 

100 

101class ListCommand(object): 

102 

103 def list_json(self, resource_name, resource_list): 

104 resource_list = {resource_name: resource_list} 

105 object_list = jsonutils.dumps(resource_list, indent=4) 

106 print(object_list) 

107 

108 def list_yaml(self, resource_name, resource_list): 

109 resource_list = {resource_name: resource_list} 

110 data_yaml = yaml.dump(resource_list) 

111 print(data_yaml) 

112 

113 def list_table(self, resource_name, resource_list): 

114 print_format = "{0:<16} {1:<36} {2:<16} {3:<10} {4:<5} {5:<10}" 

115 print(print_format.format( 

116 *[k.capitalize().replace( 

117 '_', ' ') for k in resource_list[0].keys()])) 

118 for resource in resource_list: 

119 # Print is not transforming into a string, so let's ensure it 

120 # happens 

121 resource['updated_at'] = str(resource['updated_at']) 

122 print(print_format.format(*resource.values())) 

123 

124 def _check_format_output(self, format_output): 

125 if format_output not in ALLOWED_OUTPUT_FORMATS: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 print('Invalid output format specified. Defaulting to table.') 

127 return 'table' 

128 else: 

129 return format_output 

130 

131 

132class ShellCommands(object): 

133 def bpython(self): 

134 """Runs a bpython shell. 

135 

136 Falls back to Ipython/python shell if unavailable 

137 """ 

138 self.run('bpython') 

139 

140 def ipython(self): 

141 """Runs an Ipython shell. 

142 

143 Falls back to Python shell if unavailable 

144 """ 

145 self.run('ipython') 

146 

147 def python(self): 

148 """Runs a python shell. 

149 

150 Falls back to Python shell if unavailable 

151 """ 

152 self.run('python') 

153 

154 @args('--shell', dest="shell", 

155 metavar='<bpython|ipython|python>', 

156 help='Python shell') 

157 def run(self, shell=None): 

158 """Runs a Python interactive interpreter.""" 

159 if not shell: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true

160 shell = 'bpython' 

161 

162 if shell == 'bpython': 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 try: 

164 import bpython 

165 bpython.embed() 

166 except ImportError: 

167 shell = 'ipython' 

168 if shell == 'ipython': 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true

169 try: 

170 from IPython import embed 

171 embed() 

172 except ImportError: 

173 # Ipython < 0.11 

174 try: 

175 import IPython 

176 

177 # Explicitly pass an empty list as arguments, because 

178 # otherwise IPython would use sys.argv from this script. 

179 shell = IPython.Shell.IPShell(argv=[]) 

180 shell.mainloop() 

181 except ImportError: 

182 # no IPython module 

183 shell = 'python' 

184 

185 if shell == 'python': 185 ↛ exitline 185 didn't return from function 'run' because the condition on line 185 was always true

186 import code 

187 try: 

188 # Try activating rlcompleter, because it's handy. 

189 import readline 

190 except ImportError: 

191 pass 

192 else: 

193 # We don't have to wrap the following import in a 'try', 

194 # because we already know 'readline' was imported successfully. 

195 import rlcompleter # noqa 

196 readline.parse_and_bind("tab:complete") 

197 code.interact() 

198 

199 @args('--path', required=True, help='Script path') 

200 def script(self, path): 

201 """Runs the script from the specified path with flags set properly. 

202 

203 arguments: path 

204 """ 

205 exec(compile(open(path).read(), path, 'exec'), locals(), globals()) 

206 

207 

208class HostCommands(object): 

209 """List hosts.""" 

210 

211 @args('zone', nargs='?', default=None, 

212 help='Availability Zone (default: %(default)s)') 

213 def list(self, zone=None): 

214 """Show a list of all physical hosts. Filter by zone. 

215 

216 args: [zone] 

217 """ 

218 print("%-25s\t%-15s" % (_('host'), _('zone'))) 

219 ctxt = context.get_admin_context() 

220 services = db.service_get_all(ctxt) 

221 if zone: 

222 services = [ 

223 s for s in services if s['availability_zone']['name'] == zone] 

224 hosts = [] 

225 for srv in services: 

226 if not [h for h in hosts if h['host'] == srv['host']]: 226 ↛ 225line 226 didn't jump to line 225 because the condition on line 226 was always true

227 hosts.append(srv) 

228 

229 for h in hosts: 

230 print("%-25s\t%-15s" % (h['host'], h['availability_zone']['name'])) 

231 

232 

233class DbCommands(object): 

234 """Class for managing the database.""" 

235 

236 def __init__(self): 

237 pass 

238 

239 @args('version', nargs='?', default=None, 

240 help='Database version') 

241 def sync(self, version=None): 

242 """Sync the database up to the most recent version.""" 

243 return migration.upgrade(version) 

244 

245 def version(self): 

246 """Print the current database version.""" 

247 print(migration.version()) 

248 

249 # NOTE(imalinovskiy): 

250 # Manila init migration hardcoded here, 

251 # because alembic has strange behaviour: 

252 # downgrade base = downgrade from head(162a3e673105) -> base(162a3e673105) 

253 # = downgrade from 162a3e673105 -> (empty) [ERROR] 

254 # downgrade 162a3e673105 = downgrade from head(162a3e673105)->162a3e673105 

255 # = do nothing [OK] 

256 @args('version', nargs='?', default='162a3e673105', 

257 help='Version to downgrade') 

258 def downgrade(self, version=None): 

259 """Downgrade database to the given version.""" 

260 return migration.downgrade(version) 

261 

262 @args('--message', help='Revision message') 

263 @args('--autogenerate', help='Autogenerate migration from schema') 

264 def revision(self, message, autogenerate): 

265 """Generate new migration.""" 

266 return migration.revision(message, autogenerate) 

267 

268 @args('version', nargs='?', default=None, 

269 help='Version to stamp version table with') 

270 def stamp(self, version=None): 

271 """Stamp the version table with the given version.""" 

272 return migration.stamp(version) 

273 

274 @args('age_in_days', type=int, default=0, nargs='?', 

275 help='A non-negative integer, denoting the age of soft-deleted ' 

276 'records in number of days. 0 can be specified to purge all ' 

277 'soft-deleted rows, default is %(default)d.') 

278 def purge(self, age_in_days): 

279 """Purge soft-deleted records older than a given age.""" 

280 age_in_days = int(age_in_days) 

281 if age_in_days < 0: 

282 print(_("Must supply a non-negative value for age.")) 

283 exit(1) 

284 ctxt = context.get_admin_context() 

285 db.purge_deleted_records(ctxt, age_in_days) 

286 

287 

288class VersionCommands(object): 

289 """Class for exposing the codebase version.""" 

290 

291 def list(self): 

292 print(version.version_string()) 

293 

294 def __call__(self): 

295 self.list() 

296 

297 

298class ConfigCommands(object): 

299 """Class for exposing the flags defined by flag_file(s).""" 

300 

301 def list(self): 

302 for key, value in CONF.items(): 

303 if value is not None: 

304 print('%s = %s' % (key, value)) 

305 

306 

307class GetLogCommands(object): 

308 """Get logging information.""" 

309 

310 def errors(self): 

311 """Get all of the errors from the log files.""" 

312 error_found = 0 

313 if CONF.log_dir: 

314 logs = [x for x in os.listdir(CONF.log_dir) if x.endswith('.log')] 

315 for file in logs: 

316 log_file = os.path.join(CONF.log_dir, file) 

317 lines = [line.strip() for line in open(log_file, "r")] 

318 lines.reverse() 

319 print_name = 0 

320 for index, line in enumerate(lines): 

321 if line.find(" ERROR ") > 0: 321 ↛ 320line 321 didn't jump to line 320 because the condition on line 321 was always true

322 error_found += 1 

323 if print_name == 0: 323 ↛ 326line 323 didn't jump to line 326 because the condition on line 323 was always true

324 print(log_file + ":-") 

325 print_name = 1 

326 print("Line %d : %s" % (len(lines) - index, line)) 

327 if error_found == 0: 

328 print("No errors in logfiles!") 

329 

330 @args('num_entries', nargs='?', type=int, default=10, 

331 help='Number of entries to list (default: %(default)d)') 

332 def syslog(self, num_entries=10): 

333 """Get <num_entries> of the manila syslog events.""" 

334 entries = int(num_entries) 

335 count = 0 

336 log_file = '' 

337 if os.path.exists('/var/log/syslog'): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 log_file = '/var/log/syslog' 

339 elif os.path.exists('/var/log/messages'): 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 log_file = '/var/log/messages' 

341 else: 

342 print("Unable to find system log file!") 

343 sys.exit(1) 

344 lines = [line.strip() for line in open(log_file, "r")] 

345 lines.reverse() 

346 print("Last %s manila syslog entries:-" % (entries)) 

347 for line in lines: 

348 if line.find("manila") > 0: 

349 count += 1 

350 print("%s" % (line)) 

351 if count == entries: 

352 break 

353 

354 if count == 0: 

355 print("No manila entries in syslog!") 

356 

357 

358class ServiceCommands(ListCommand): 

359 """Methods for managing services.""" 

360 

361 @args('--format_output', required=False, default='table', 

362 help=LIST_OUTPUT_FORMAT_HELP) 

363 def list(self, format_output): 

364 """Show a list of all manila services.""" 

365 ctxt = context.get_admin_context() 

366 services = db.service_get_all(ctxt) 

367 format_output = self._check_format_output(format_output) 

368 

369 services_list = [] 

370 for service in services: 

371 alive = utils.service_is_up(service) 

372 state = ":-)" if alive else "XXX" 

373 status = 'enabled' 

374 if service['disabled']: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true

375 status = 'disabled' 

376 services_list.append({ 

377 'binary': service['binary'], 

378 'host': service['host'].partition('.')[0], 

379 'zone': service['availability_zone']['name'], 

380 'status': status, 

381 'state': state, 

382 'updated_at': str(service['updated_at']), 

383 }) 

384 

385 method_list_name = f'list_{format_output}' 

386 getattr(self, method_list_name)('services', services_list) 

387 

388 def cleanup(self): 

389 """Remove manila services reporting as 'down'.""" 

390 ctxt = context.get_admin_context() 

391 services = db.service_get_all(ctxt) 

392 

393 for svc in services: 

394 if utils.service_is_up(svc): 

395 continue 

396 db.service_destroy(ctxt, svc['id']) 

397 print("Cleaned up service %s" % svc['host']) 

398 

399 

400class ShareCommands(object): 

401 

402 @staticmethod 

403 def _validate_hosts(current_host, new_host): 

404 err = None 

405 if '@' in current_host: 

406 if '#' in current_host and '#' not in new_host: 

407 err = "%(chost)s specifies a pool but %(nhost)s does not." 

408 elif '@' not in new_host: 

409 err = "%(chost)s specifies a backend but %(nhost)s does not." 

410 if err: 

411 print(err % {'chost': current_host, 'nhost': new_host}) 

412 sys.exit(1) 

413 

414 @args('--currenthost', required=True, help=HOST_UPDATE_CURRENT_HOST_HELP) 

415 @args('--newhost', required=True, help=HOST_UPDATE_NEW_HOST_HELP) 

416 @args('--force', required=False, type=bool, default=False, 

417 help="Ignore validations.") 

418 def update_host(self, current_host, new_host, force=False): 

419 """Modify the host name associated with resources. 

420 

421 Particularly to recover from cases where one has moved 

422 their Manila Share node, or modified their 'host' opt 

423 or their backend section name in the manila configuration file. 

424 Affects shares, share servers and share groups 

425 """ 

426 if not force: 

427 self._validate_hosts(current_host, new_host) 

428 ctxt = context.get_admin_context() 

429 updated = db.share_resources_host_update(ctxt, current_host, new_host) 

430 msg = ("Updated host of %(si_count)d share instances, " 

431 "%(sg_count)d share groups and %(ss_count)d share servers on " 

432 "%(chost)s to %(nhost)s.") 

433 msg_args = { 

434 'si_count': updated['instances'], 

435 'sg_count': updated['groups'], 

436 'ss_count': updated['servers'], 

437 'chost': current_host, 

438 'nhost': new_host, 

439 } 

440 print(msg % msg_args) 

441 

442 @args('--share_id', required=True, help=SHARE_DELETE_HELP) 

443 def delete(self, share_id): 

444 """Delete manila share from the database. 

445 

446 This command is useful after a share's manager service 

447 has been decommissioned. 

448 """ 

449 ctxt = context.get_admin_context() 

450 share = db.share_get(ctxt, share_id) 

451 

452 active_replicas = [] 

453 # We delete "active" replicas at the end 

454 for share_instance in share['instances']: 

455 if share_instance['replica_state'] == "active": 

456 active_replicas.append(share_instance) 

457 else: 

458 db.share_instance_delete(ctxt, share_instance['id']) 

459 for share_instance in active_replicas: 

460 db.share_instance_delete(ctxt, share_instance['id']) 

461 print("Deleted share instance %s" % share_instance['id']) 

462 

463 # finally, clean up the share 

464 print("Deleted share %s" % share_id) 

465 

466 

467class ShareServerCommands(object): 

468 @args('--share_servers', required=True, 

469 help=SHARE_SERVERS_UPDATE_HELP) 

470 @args('--capabilities', required=True, 

471 help=SHARE_SERVERS_UPDATE_CAPABILITIES_HELP) 

472 @args('--value', required=False, type=bool, default=False, 

473 help="If those capabilities will be enabled (True) or disabled " 

474 "(False)") 

475 def update_share_server_capabilities(self, share_servers, capabilities, 

476 value=False): 

477 """Update the share server capabilities. 

478 

479 This method receives a list of share servers and capabilities 

480 in order to have it updated with the value specified. If the value 

481 was not specified the default is False. 

482 """ 

483 share_servers = [server.strip() for server in share_servers.split(",")] 

484 capabilities = [cap.strip() for cap in capabilities.split(",")] 

485 supported_capabilities = ['security_service_update_support', 

486 'network_allocation_update_support'] 

487 

488 values = dict() 

489 for capability in capabilities: 

490 if capability not in supported_capabilities: 

491 print("One or more capabilities are invalid for this " 

492 "operation. The supported capability(ies) is(are) %s." 

493 % supported_capabilities) 

494 sys.exit(1) 

495 values[capability] = value 

496 

497 ctxt = context.get_admin_context() 

498 db.share_servers_update(ctxt, share_servers, values) 

499 print("The capability(ies) %s of the following share server(s)" 

500 " %s was(were) updated to %s." % 

501 (capabilities, share_servers, value)) 

502 

503 

504CATEGORIES = { 

505 'config': ConfigCommands, 

506 'db': DbCommands, 

507 'host': HostCommands, 

508 'logs': GetLogCommands, 

509 'service': ServiceCommands, 

510 'share': ShareCommands, 

511 'share_server': ShareServerCommands, 

512 'shell': ShellCommands, 

513 'version': VersionCommands 

514} 

515 

516 

517def methods_of(obj): 

518 """Get all callable methods of an object that don't start with underscore. 

519 

520 Returns a list of tuples of the form (method_name, method). 

521 """ 

522 result = [] 

523 for i in dir(obj): 

524 if callable(getattr(obj, i)) and not i.startswith('_'): 

525 result.append((i, getattr(obj, i))) 

526 return result 

527 

528 

529def add_command_parsers(subparsers): 

530 for category in CATEGORIES: 

531 command_object = CATEGORIES[category]() 

532 

533 parser = subparsers.add_parser(category) 

534 parser.set_defaults(command_object=command_object) 

535 

536 category_subparsers = parser.add_subparsers(dest='action') 

537 

538 for (action, action_fn) in methods_of(command_object): 

539 parser = category_subparsers.add_parser(action) 

540 

541 action_kwargs = [] 

542 for args, kwargs in getattr(action_fn, 'args', []): 

543 parser.add_argument(*args, **kwargs) 

544 

545 parser.set_defaults(action_fn=action_fn) 

546 parser.set_defaults(action_kwargs=action_kwargs) 

547 

548 

549category_opt = cfg.SubCommandOpt('category', 

550 title='Command categories', 

551 handler=add_command_parsers) 

552 

553 

554def get_arg_string(args): 

555 arg = None 

556 if args[0] == '-': 

557 # (Note)zhiteng: args starts with CONF.oparser.prefix_chars 

558 # is optional args. Notice that cfg module takes care of 

559 # actual ArgParser so prefix_chars is always '-'. 

560 if args[1] == '-': 

561 # This is long optional arg 

562 arg = args[2:] 

563 else: 

564 arg = args[1:] 

565 else: 

566 arg = args 

567 

568 return arg 

569 

570 

571def fetch_func_args(func): 

572 fn_args = [] 

573 for args, kwargs in getattr(func, 'args', []): 573 ↛ 574line 573 didn't jump to line 574 because the loop on line 573 never started

574 arg = get_arg_string(args[0]) 

575 fn_args.append(getattr(CONF.category, arg)) 

576 

577 return fn_args 

578 

579 

580def main(): 

581 """Parse options and call the appropriate class/method.""" 

582 CONF.register_cli_opt(category_opt) 

583 script_name = sys.argv[0] 

584 if len(sys.argv) < 2: 

585 print(_("\nOpenStack manila version: %(version)s\n") % 

586 {'version': version.version_string()}) 

587 print(script_name + " category action [<args>]") 

588 print(_("Available categories:")) 

589 for category in CATEGORIES: 

590 print("\t%s" % category) 

591 sys.exit(2) 

592 

593 try: 

594 log.register_options(CONF) 

595 CONF(sys.argv[1:], project='manila', 

596 version=version.version_string()) 

597 log.setup(CONF, "manila") 

598 except cfg.ConfigFilesNotFoundError as e: 

599 cfg_files = e.config_files 

600 print(_("Failed to read configuration file(s): %s") % cfg_files) 

601 sys.exit(2) 

602 

603 fn = CONF.category.action_fn 

604 

605 fn_args = fetch_func_args(fn) 

606 fn(*fn_args) 

607 

608 

609if __name__ == '__main__': 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true

610 main()