1#!/usr/bin/env python 2# Copyright (c) 2011 X.commerce, a business unit of eBay Inc. 3# Copyright 2010 United States Government as represented by the 4# Administrator of the National Aeronautics and Space Administration. 5# All Rights Reserved. 6# 7# Licensed under the Apache License, Version 2.0 (the "License"); you may 8# not use this file except in compliance with the License. You may obtain 9# a copy of the License at 10# 11# http://www.apache.org/licenses/LICENSE-2.0 12# 13# Unless required by applicable law or agreed to in writing, software 14# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 15# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 16# License for the specific language governing permissions and limitations 17# under the License. 18 19# Interactive shell based on Django: 20# 21# Copyright (c) 2005, the Lawrence Journal-World 22# All rights reserved. 23# 24# Redistribution and use in source and binary forms, with or without 25# modification, are permitted provided that the following conditions are met: 26# 27# 1. Redistributions of source code must retain the above copyright notice, 28# this list of conditions and the following disclaimer. 29# 30# 2. Redistributions in binary form must reproduce the above copyright 31# notice, this list of conditions and the following disclaimer in the 32# documentation and/or other materials provided with the distribution. 33# 34# 3. Neither the name of Django nor the names of its contributors may be 35# used to endorse or promote products derived from this software without 36# specific prior written permission. 37# 38# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 39# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 40# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 41# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 42# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 43# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 44# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 45# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 46# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 47# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 48# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 49 50 51""" 52 CLI interface for cinder management. 53""" 54 55from __future__ import print_function 56 57 58import logging as python_logging 59import os 60import prettytable 61import sys 62import time 63 64from oslo_config import cfg 65from oslo_db import exception as db_exc 66from oslo_db.sqlalchemy import migration 67from oslo_log import log as logging 68from oslo_utils import timeutils 69 70# Need to register global_opts 71from cinder.backup import rpcapi as backup_rpcapi 72from cinder.common import config # noqa 73from cinder.common import constants 74from cinder import context 75from cinder import db 76from cinder.db import migration as db_migration 77from cinder.db.sqlalchemy import api as db_api 78from cinder.db.sqlalchemy import models 79from cinder import exception 80from cinder.i18n import _ 81from cinder import objects 82from cinder.objects import base as ovo_base 83from cinder import rpc 84from cinder.scheduler import rpcapi as scheduler_rpcapi 85from cinder import version 86from cinder.volume import rpcapi as volume_rpcapi 87from cinder.volume import utils as vutils 88 89 90CONF = cfg.CONF 91 92RPC_VERSIONS = { 93 'cinder-scheduler': scheduler_rpcapi.SchedulerAPI.RPC_API_VERSION, 94 'cinder-volume': volume_rpcapi.VolumeAPI.RPC_API_VERSION, 95 'cinder-backup': backup_rpcapi.BackupAPI.RPC_API_VERSION, 96} 97 98OVO_VERSION = ovo_base.OBJ_VERSIONS.get_current() 99 100 101def _get_non_shared_target_hosts(ctxt): 102 hosts = [] 103 numvols_needing_update = 0 104 rpc.init(CONF) 105 rpcapi = volume_rpcapi.VolumeAPI() 106 107 services = objects.ServiceList.get_all_by_topic(ctxt, 108 constants.VOLUME_TOPIC) 109 for service in services: 110 capabilities = rpcapi.get_capabilities(ctxt, service.host, True) 111 # Select only non iSCSI connections and iSCSI that are explicit 112 if (capabilities.get('storage_protocol') != 'iSCSI' or 113 not capabilities.get('shared_targets', True)): 114 hosts.append(service.host) 115 numvols_needing_update += db_api.model_query( 116 ctxt, models.Volume).filter_by( 117 shared_targets=True, 118 service_uuid=service.uuid).count() 119 return hosts, numvols_needing_update 120 121 122def shared_targets_online_data_migration(ctxt, max_count): 123 """Update existing volumes shared_targets flag based on capabilities.""" 124 non_shared_hosts = [] 125 completed = 0 126 127 non_shared_hosts, total_vols_to_update = _get_non_shared_target_hosts(ctxt) 128 for host in non_shared_hosts: 129 # We use the api call here instead of going direct to 130 # db query to take advantage of parsing out the host 131 # correctly 132 vrefs = db_api.volume_get_all_by_host( 133 ctxt, host, 134 filters={'shared_targets': True}) 135 if len(vrefs) > max_count: 136 del vrefs[-(len(vrefs) - max_count):] 137 max_count -= len(vrefs) 138 for v in vrefs: 139 db.volume_update( 140 ctxt, v['id'], 141 {'shared_targets': 0}) 142 completed += 1 143 return total_vols_to_update, completed 144 145 146# Decorators for actions 147def args(*args, **kwargs): 148 def _decorator(func): 149 func.__dict__.setdefault('args', []).insert(0, (args, kwargs)) 150 return func 151 return _decorator 152 153 154class ShellCommands(object): 155 def bpython(self): 156 """Runs a bpython shell. 157 158 Falls back to Ipython/python shell if unavailable 159 """ 160 self.run('bpython') 161 162 def ipython(self): 163 """Runs an Ipython shell. 164 165 Falls back to Python shell if unavailable 166 """ 167 self.run('ipython') 168 169 def python(self): 170 """Runs a python shell. 171 172 Falls back to Python shell if unavailable 173 """ 174 self.run('python') 175 176 @args('--shell', 177 metavar='<bpython|ipython|python>', 178 help='Python shell') 179 def run(self, shell=None): 180 """Runs a Python interactive interpreter.""" 181 if not shell: 182 shell = 'bpython' 183 184 if shell == 'bpython': 185 try: 186 import bpython 187 bpython.embed() 188 except ImportError: 189 shell = 'ipython' 190 if shell == 'ipython': 191 try: 192 from IPython import embed 193 embed() 194 except ImportError: 195 try: 196 # Ipython < 0.11 197 # Explicitly pass an empty list as arguments, because 198 # otherwise IPython would use sys.argv from this script. 199 import IPython 200 201 shell = IPython.Shell.IPShell(argv=[]) 202 shell.mainloop() 203 except ImportError: 204 # no IPython module 205 shell = 'python' 206 207 if shell == 'python': 208 import code 209 try: 210 # Try activating rlcompleter, because it's handy. 211 import readline 212 except ImportError: 213 pass 214 else: 215 # We don't have to wrap the following import in a 'try', 216 # because we already know 'readline' was imported successfully. 217 import rlcompleter # noqa 218 readline.parse_and_bind("tab:complete") 219 code.interact() 220 221 @args('--path', required=True, help='Script path') 222 def script(self, path): 223 """Runs the script from the specified path with flags set properly.""" 224 exec(compile(open(path).read(), path, 'exec'), locals(), globals()) 225 226 227def _db_error(caught_exception): 228 print('%s' % caught_exception) 229 print(_("The above error may show that the database has not " 230 "been created.\nPlease create a database using " 231 "'cinder-manage db sync' before running this command.")) 232 sys.exit(1) 233 234 235class HostCommands(object): 236 """List hosts.""" 237 238 @args('zone', nargs='?', default=None, 239 help='Availability Zone (default: %(default)s)') 240 def list(self, zone=None): 241 """Show a list of all physical hosts. 242 243 Can be filtered by zone. 244 args: [zone] 245 """ 246 print(_("%(host)-25s\t%(zone)-15s") % {'host': 'host', 'zone': 'zone'}) 247 ctxt = context.get_admin_context() 248 services = objects.ServiceList.get_all(ctxt) 249 if zone: 250 services = [s for s in services if s.availability_zone == zone] 251 hosts = [] 252 for srv in services: 253 if not [h for h in hosts if h['host'] == srv['host']]: 254 hosts.append(srv) 255 256 for h in hosts: 257 print(_("%(host)-25s\t%(availability_zone)-15s") 258 % {'host': h['host'], 259 'availability_zone': h['availability_zone']}) 260 261 262class DbCommands(object): 263 """Class for managing the database.""" 264 265 online_migrations = ( 266 # Added in Queens 267 db.service_uuids_online_data_migration, 268 # Added in Queens 269 db.backup_service_online_migration, 270 # Added in Queens 271 db.volume_service_uuids_online_data_migration, 272 # Added in Queens 273 shared_targets_online_data_migration, 274 # Added in Queens 275 db.attachment_specs_online_data_migration 276 ) 277 278 def __init__(self): 279 pass 280 281 @args('version', nargs='?', default=None, type=int, 282 help='Database version') 283 @args('--bump-versions', dest='bump_versions', default=False, 284 action='store_true', 285 help='Update RPC and Objects versions when doing offline upgrades, ' 286 'with this we no longer need to restart the services twice ' 287 'after the upgrade to prevent ServiceTooOld exceptions.') 288 def sync(self, version=None, bump_versions=False): 289 """Sync the database up to the most recent version.""" 290 if version is not None and version > db.MAX_INT: 291 print(_('Version should be less than or equal to ' 292 '%(max_version)d.') % {'max_version': db.MAX_INT}) 293 sys.exit(1) 294 try: 295 result = db_migration.db_sync(version) 296 except db_exc.DBMigrationError as ex: 297 print("Error during database migration: %s" % ex) 298 sys.exit(1) 299 300 try: 301 if bump_versions: 302 ctxt = context.get_admin_context() 303 services = objects.ServiceList.get_all(ctxt) 304 for service in services: 305 rpc_version = RPC_VERSIONS[service.binary] 306 if (service.rpc_current_version != rpc_version or 307 service.object_current_version != OVO_VERSION): 308 service.rpc_current_version = rpc_version 309 service.object_current_version = OVO_VERSION 310 service.save() 311 except Exception as ex: 312 print(_('Error during service version bump: %s') % ex) 313 sys.exit(2) 314 315 return result 316 317 def version(self): 318 """Print the current database version.""" 319 print(migration.db_version(db_api.get_engine(), 320 db_migration.MIGRATE_REPO_PATH, 321 db_migration.INIT_VERSION)) 322 323 @args('age_in_days', type=int, 324 help='Purge deleted rows older than age in days') 325 def purge(self, age_in_days): 326 """Purge deleted rows older than a given age from cinder tables.""" 327 age_in_days = int(age_in_days) 328 if age_in_days < 0: 329 print(_("Must supply a positive value for age")) 330 sys.exit(1) 331 if age_in_days >= (int(time.time()) / 86400): 332 print(_("Maximum age is count of days since epoch.")) 333 sys.exit(1) 334 ctxt = context.get_admin_context() 335 336 try: 337 db.purge_deleted_rows(ctxt, age_in_days) 338 except db_exc.DBReferenceError: 339 print(_("Purge command failed, check cinder-manage " 340 "logs for more details.")) 341 sys.exit(1) 342 343 def _run_migration(self, ctxt, max_count): 344 ran = 0 345 migrations = {} 346 for migration_meth in self.online_migrations: 347 count = max_count - ran 348 try: 349 found, done = migration_meth(ctxt, count) 350 except Exception: 351 print(_("Error attempting to run %(method)s") % 352 {'method': migration_meth.__name__}) 353 found = done = 0 354 355 name = migration_meth.__name__ 356 remaining = found - done 357 if found: 358 print(_('%(found)i rows matched query %(meth)s, %(done)i ' 359 'migrated, %(remaining)i remaining') % {'found': found, 360 'meth': name, 361 'done': done, 362 'remaining': 363 remaining}) 364 migrations.setdefault(name, (0, 0, 0)) 365 migrations[name] = (migrations[name][0] + found, 366 migrations[name][1] + done, 367 migrations[name][2] + remaining) 368 if max_count is not None: 369 ran += done 370 if ran >= max_count: 371 break 372 return migrations 373 374 @args('--max_count', metavar='<number>', dest='max_count', type=int, 375 help='Maximum number of objects to consider.') 376 def online_data_migrations(self, max_count=None): 377 """Perform online data migrations for the release in batches.""" 378 ctxt = context.get_admin_context() 379 if max_count is not None: 380 unlimited = False 381 if max_count < 1: 382 print(_('Must supply a positive value for max_number.')) 383 sys.exit(127) 384 else: 385 unlimited = True 386 max_count = 50 387 print(_('Running batches of %i until complete.') % max_count) 388 389 # FIXME(jdg): So this is annoying and confusing, 390 # we iterate through in batches until there are no 391 # more updates, that's AWESOME!! BUT we only print 392 # out a table reporting found/done AFTER the loop 393 # here, so that means the response the user sees is 394 # always a table of "needed 0" and "completed 0". 395 # So it's an indication of "all done" but it seems like 396 # some feedback as we go would be nice to have here. 397 ran = None 398 migration_info = {} 399 while ran is None or ran != 0: 400 migrations = self._run_migration(ctxt, max_count) 401 migration_info.update(migrations) 402 ran = sum([done for found, done, remaining in migrations.values()]) 403 if not unlimited: 404 break 405 headers = ["{}".format(_('Migration')), 406 "{}".format(_('Total Needed')), 407 "{}".format(_('Completed')), ] 408 t = prettytable.PrettyTable(headers) 409 for name in sorted(migration_info.keys()): 410 info = migration_info[name] 411 t.add_row([name, info[0], info[1]]) 412 print(t) 413 414 sys.exit(1 if ran else 0) 415 416 417class VersionCommands(object): 418 """Class for exposing the codebase version.""" 419 420 def __init__(self): 421 pass 422 423 def list(self): 424 print(version.version_string()) 425 426 def __call__(self): 427 self.list() 428 429 430class VolumeCommands(object): 431 """Methods for dealing with a cloud in an odd state.""" 432 433 @args('volume_id', 434 help='Volume ID to be deleted') 435 def delete(self, volume_id): 436 """Delete a volume, bypassing the check that it must be available.""" 437 ctxt = context.get_admin_context() 438 volume = objects.Volume.get_by_id(ctxt, volume_id) 439 host = vutils.extract_host(volume.host) if volume.host else None 440 441 if not host: 442 print(_("Volume not yet assigned to host.")) 443 print(_("Deleting volume from database and skipping rpc.")) 444 volume.destroy() 445 return 446 447 if volume.status == 'in-use': 448 print(_("Volume is in-use.")) 449 print(_("Detach volume from instance and then try again.")) 450 return 451 452 rpc.init(CONF) 453 rpcapi = volume_rpcapi.VolumeAPI() 454 rpcapi.delete_volume(ctxt, volume) 455 456 @args('--currenthost', required=True, help='Existing volume host name') 457 @args('--newhost', required=True, help='New volume host name') 458 def update_host(self, currenthost, newhost): 459 """Modify the host name associated with a volume. 460 461 Particularly to recover from cases where one has moved 462 their Cinder Volume node, or modified their backend_name in a 463 multi-backend config. 464 """ 465 ctxt = context.get_admin_context() 466 volumes = db.volume_get_all_by_host(ctxt, 467 currenthost) 468 for v in volumes: 469 db.volume_update(ctxt, v['id'], 470 {'host': newhost}) 471 472 473class ConfigCommands(object): 474 """Class for exposing the flags defined by flag_file(s).""" 475 476 def __init__(self): 477 pass 478 479 @args('param', nargs='?', default=None, 480 help='Configuration parameter to display (default: %(default)s)') 481 def list(self, param=None): 482 """List parameters configured for cinder. 483 484 Lists all parameters configured for cinder unless an optional argument 485 is specified. If the parameter is specified we only print the 486 requested parameter. If the parameter is not found an appropriate 487 error is produced by .get*(). 488 """ 489 param = param and param.strip() 490 if param: 491 print('%s = %s' % (param, CONF.get(param))) 492 else: 493 for key, value in CONF.items(): 494 print('%s = %s' % (key, value)) 495 496 497class GetLogCommands(object): 498 """Get logging information.""" 499 500 deprecation_msg = ('DEPRECATED: The log commands are deprecated ' 501 'since Queens and are not maintained. They will be ' 502 'removed in an upcoming release.') 503 504 def errors(self): 505 """Get all of the errors from the log files.""" 506 507 print(self.deprecation_msg) 508 509 error_found = 0 510 if CONF.log_dir: 511 logs = [x for x in os.listdir(CONF.log_dir) if x.endswith('.log')] 512 for file in logs: 513 log_file = os.path.join(CONF.log_dir, file) 514 lines = [line.strip() for line in open(log_file, "r")] 515 lines.reverse() 516 print_name = 0 517 for index, line in enumerate(lines): 518 if line.find(" ERROR ") > 0: 519 error_found += 1 520 if print_name == 0: 521 print(log_file + ":-") 522 print_name = 1 523 print(_("Line %(dis)d : %(line)s") % 524 {'dis': len(lines) - index, 'line': line}) 525 if error_found == 0: 526 print(_("No errors in logfiles!")) 527 528 @args('num_entries', nargs='?', type=int, default=10, 529 help='Number of entries to list (default: %(default)d)') 530 def syslog(self, num_entries=10): 531 """Get <num_entries> of the cinder syslog events.""" 532 533 print(self.deprecation_msg) 534 535 entries = int(num_entries) 536 count = 0 537 log_file = '' 538 if os.path.exists('/var/log/syslog'): 539 log_file = '/var/log/syslog' 540 elif os.path.exists('/var/log/messages'): 541 log_file = '/var/log/messages' 542 else: 543 print(_("Unable to find system log file!")) 544 sys.exit(1) 545 lines = [line.strip() for line in open(log_file, "r")] 546 lines.reverse() 547 print(_("Last %s cinder syslog entries:-") % (entries)) 548 for line in lines: 549 if line.find("cinder") > 0: 550 count += 1 551 print(_("%s") % (line)) 552 if count == entries: 553 break 554 555 if count == 0: 556 print(_("No cinder entries in syslog!")) 557 558 559class BackupCommands(object): 560 """Methods for managing backups.""" 561 562 def list(self): 563 """List all backups. 564 565 List all backups (including ones in progress) and the host 566 on which the backup operation is running. 567 """ 568 ctxt = context.get_admin_context() 569 backups = objects.BackupList.get_all(ctxt) 570 571 hdr = "%-32s\t%-32s\t%-32s\t%-24s\t%-24s\t%-12s\t%-12s\t%-12s\t%-12s" 572 print(hdr % (_('ID'), 573 _('User ID'), 574 _('Project ID'), 575 _('Host'), 576 _('Name'), 577 _('Container'), 578 _('Status'), 579 _('Size'), 580 _('Object Count'))) 581 582 res = "%-32s\t%-32s\t%-32s\t%-24s\t%-24s\t%-12s\t%-12s\t%-12d\t%-12d" 583 for backup in backups: 584 object_count = 0 585 if backup['object_count'] is not None: 586 object_count = backup['object_count'] 587 print(res % (backup['id'], 588 backup['user_id'], 589 backup['project_id'], 590 backup['host'], 591 backup['display_name'], 592 backup['container'], 593 backup['status'], 594 backup['size'], 595 object_count)) 596 597 @args('--currenthost', required=True, help='Existing backup host name') 598 @args('--newhost', required=True, help='New backup host name') 599 def update_backup_host(self, currenthost, newhost): 600 """Modify the host name associated with a backup. 601 602 Particularly to recover from cases where one has moved 603 their Cinder Backup node, and not set backup_use_same_backend. 604 """ 605 ctxt = context.get_admin_context() 606 backups = objects.BackupList.get_all_by_host(ctxt, currenthost) 607 for bk in backups: 608 bk.host = newhost 609 bk.save() 610 611 612class BaseCommand(object): 613 @staticmethod 614 def _normalize_time(time_field): 615 return time_field and timeutils.normalize_time(time_field) 616 617 @staticmethod 618 def _state_repr(is_up): 619 return ':-)' if is_up else 'XXX' 620 621 622class ServiceCommands(BaseCommand): 623 """Methods for managing services.""" 624 def list(self): 625 """Show a list of all cinder services.""" 626 ctxt = context.get_admin_context() 627 services = objects.ServiceList.get_all(ctxt) 628 print_format = "%-16s %-36s %-16s %-10s %-5s %-20s %-12s %-15s %-36s" 629 print(print_format % (_('Binary'), 630 _('Host'), 631 _('Zone'), 632 _('Status'), 633 _('State'), 634 _('Updated At'), 635 _('RPC Version'), 636 _('Object Version'), 637 _('Cluster'))) 638 for svc in services: 639 art = self._state_repr(svc.is_up) 640 status = 'disabled' if svc.disabled else 'enabled' 641 updated_at = self._normalize_time(svc.updated_at) 642 rpc_version = svc.rpc_current_version 643 object_version = svc.object_current_version 644 cluster = svc.cluster_name or '' 645 print(print_format % (svc.binary, svc.host, 646 svc.availability_zone, status, art, 647 updated_at, rpc_version, object_version, 648 cluster)) 649 650 @args('binary', type=str, 651 help='Service to delete from the host.') 652 @args('host_name', type=str, 653 help='Host from which to remove the service.') 654 def remove(self, binary, host_name): 655 """Completely removes a service.""" 656 ctxt = context.get_admin_context() 657 try: 658 svc = objects.Service.get_by_args(ctxt, host_name, binary) 659 svc.destroy() 660 except exception.ServiceNotFound as e: 661 print(_("Host not found. Failed to remove %(service)s" 662 " on %(host)s.") % 663 {'service': binary, 'host': host_name}) 664 print(u"%s" % e.args) 665 return 2 666 print(_("Service %(service)s on host %(host)s removed.") % 667 {'service': binary, 'host': host_name}) 668 669 670class ClusterCommands(BaseCommand): 671 """Methods for managing clusters.""" 672 def list(self): 673 """Show a list of all cinder services.""" 674 ctxt = context.get_admin_context() 675 clusters = objects.ClusterList.get_all(ctxt, services_summary=True) 676 print_format = "%-36s %-16s %-10s %-5s %-20s %-7s %-12s %-20s" 677 print(print_format % (_('Name'), 678 _('Binary'), 679 _('Status'), 680 _('State'), 681 _('Heartbeat'), 682 _('Hosts'), 683 _('Down Hosts'), 684 _('Updated At'))) 685 for cluster in clusters: 686 art = self._state_repr(cluster.is_up) 687 status = 'disabled' if cluster.disabled else 'enabled' 688 heartbeat = self._normalize_time(cluster.last_heartbeat) 689 updated_at = self._normalize_time(cluster.updated_at) 690 print(print_format % (cluster.name, cluster.binary, status, art, 691 heartbeat, cluster.num_hosts, 692 cluster.num_down_hosts, updated_at)) 693 694 @args('--recursive', action='store_true', default=False, 695 help='Delete associated hosts.') 696 @args('binary', type=str, 697 help='Service to delete from the cluster.') 698 @args('cluster-name', type=str, help='Cluster to delete.') 699 def remove(self, recursive, binary, cluster_name): 700 """Completely removes a cluster.""" 701 ctxt = context.get_admin_context() 702 try: 703 cluster = objects.Cluster.get_by_id(ctxt, None, name=cluster_name, 704 binary=binary, 705 get_services=recursive) 706 except exception.ClusterNotFound: 707 print(_("Couldn't remove cluster %s because it doesn't exist.") % 708 cluster_name) 709 return 2 710 711 if recursive: 712 for service in cluster.services: 713 service.destroy() 714 715 try: 716 cluster.destroy() 717 except exception.ClusterHasHosts: 718 print(_("Couldn't remove cluster %s because it still has hosts.") % 719 cluster_name) 720 return 2 721 722 msg = _('Cluster %s successfully removed.') % cluster_name 723 if recursive: 724 msg = (_('%(msg)s And %(num)s services from the cluster were also ' 725 'removed.') % {'msg': msg, 'num': len(cluster.services)}) 726 print(msg) 727 728 @args('--full-rename', dest='partial', 729 action='store_false', default=True, 730 help='Do full cluster rename instead of just replacing provided ' 731 'current cluster name and preserving backend and/or pool info.') 732 @args('current', help='Current cluster name.') 733 @args('new', help='New cluster name.') 734 def rename(self, partial, current, new): 735 """Rename cluster name for Volumes and Consistency Groups. 736 737 Useful when you want to rename a cluster, particularly when the 738 backend_name has been modified in a multi-backend config or we have 739 moved from a single backend to multi-backend. 740 """ 741 ctxt = context.get_admin_context() 742 743 # Convert empty strings to None 744 current = current or None 745 new = new or None 746 747 # Update Volumes 748 num_vols = objects.VolumeList.include_in_cluster( 749 ctxt, new, partial_rename=partial, cluster_name=current) 750 751 # Update Consistency Groups 752 num_cgs = objects.ConsistencyGroupList.include_in_cluster( 753 ctxt, new, partial_rename=partial, cluster_name=current) 754 755 if num_vols or num_cgs: 756 msg = _('Successfully renamed %(num_vols)s volumes and ' 757 '%(num_cgs)s consistency groups from cluster %(current)s ' 758 'to %(new)s') 759 print(msg % {'num_vols': num_vols, 'num_cgs': num_cgs, 'new': new, 760 'current': current}) 761 else: 762 msg = _('No volumes or consistency groups exist in cluster ' 763 '%(current)s.') 764 print(msg % {'current': current}) 765 return 2 766 767 768class ConsistencyGroupCommands(object): 769 """Methods for managing consistency groups.""" 770 771 @args('--currenthost', required=True, help='Existing CG host name') 772 @args('--newhost', required=True, help='New CG host name') 773 def update_cg_host(self, currenthost, newhost): 774 """Modify the host name associated with a Consistency Group. 775 776 Particularly to recover from cases where one has moved 777 a host from single backend to multi-backend, or changed the host 778 configuration option, or modified the backend_name in a multi-backend 779 config. 780 """ 781 782 ctxt = context.get_admin_context() 783 groups = objects.ConsistencyGroupList.get_all( 784 ctxt, {'host': currenthost}) 785 for gr in groups: 786 gr.host = newhost 787 gr.save() 788 789 790CATEGORIES = { 791 'backup': BackupCommands, 792 'config': ConfigCommands, 793 'cluster': ClusterCommands, 794 'cg': ConsistencyGroupCommands, 795 'db': DbCommands, 796 'host': HostCommands, 797 'logs': GetLogCommands, 798 'service': ServiceCommands, 799 'shell': ShellCommands, 800 'version': VersionCommands, 801 'volume': VolumeCommands, 802} 803 804 805def methods_of(obj): 806 """Return non-private methods from an object. 807 808 Get all callable methods of an object that don't start with underscore 809 :return: a list of tuples of the form (method_name, method) 810 """ 811 result = [] 812 for i in dir(obj): 813 if callable(getattr(obj, i)) and not i.startswith('_'): 814 result.append((i, getattr(obj, i))) 815 return result 816 817 818def add_command_parsers(subparsers): 819 for category in sorted(CATEGORIES): 820 command_object = CATEGORIES[category]() 821 822 parser = subparsers.add_parser(category) 823 parser.set_defaults(command_object=command_object) 824 825 category_subparsers = parser.add_subparsers(dest='action') 826 827 for (action, action_fn) in methods_of(command_object): 828 parser = category_subparsers.add_parser(action) 829 830 action_kwargs = [] 831 for args, kwargs in getattr(action_fn, 'args', []): 832 parser.add_argument(*args, **kwargs) 833 834 parser.set_defaults(action_fn=action_fn) 835 parser.set_defaults(action_kwargs=action_kwargs) 836 837 838category_opt = cfg.SubCommandOpt('category', 839 title='Command categories', 840 handler=add_command_parsers) 841 842 843def get_arg_string(args): 844 if args[0] == '-': 845 # (Note)zhiteng: args starts with FLAGS.oparser.prefix_chars 846 # is optional args. Notice that cfg module takes care of 847 # actual ArgParser so prefix_chars is always '-'. 848 if args[1] == '-': 849 # This is long optional arg 850 args = args[2:] 851 else: 852 args = args[1:] 853 854 # We convert dashes to underscores so we can have cleaner optional arg 855 # names 856 if args: 857 args = args.replace('-', '_') 858 859 return args 860 861 862def fetch_func_args(func): 863 fn_kwargs = {} 864 for args, kwargs in getattr(func, 'args', []): 865 # Argparser `dest` configuration option takes precedence for the name 866 arg = kwargs.get('dest') or get_arg_string(args[0]) 867 fn_kwargs[arg] = getattr(CONF.category, arg) 868 869 return fn_kwargs 870 871 872def main(): 873 objects.register_all() 874 """Parse options and call the appropriate class/method.""" 875 CONF.register_cli_opt(category_opt) 876 script_name = sys.argv[0] 877 if len(sys.argv) < 2: 878 print(_("\nOpenStack Cinder version: %(version)s\n") % 879 {'version': version.version_string()}) 880 print(script_name + " category action [<args>]") 881 print(_("Available categories:")) 882 for category in CATEGORIES: 883 print(_("\t%s") % category) 884 sys.exit(2) 885 886 try: 887 CONF(sys.argv[1:], project='cinder', 888 version=version.version_string()) 889 logging.setup(CONF, "cinder") 890 python_logging.captureWarnings(True) 891 except cfg.ConfigDirNotFoundError as details: 892 print(_("Invalid directory: %s") % details) 893 sys.exit(2) 894 except cfg.ConfigFilesNotFoundError as e: 895 cfg_files = e.config_files 896 print(_("Failed to read configuration file(s): %s") % cfg_files) 897 sys.exit(2) 898 899 fn = CONF.category.action_fn 900 fn_kwargs = fetch_func_args(fn) 901 fn(**fn_kwargs) 902