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