1# Copyright 2012 OpenStack Foundation
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"""
17Command-line interface to the OpenStack Images API.
18"""
19
20import argparse
21import copy
22import getpass
23import hashlib
24import json
25import logging
26import os
27import sys
28import traceback
29
30from oslo_utils import encodeutils
31from oslo_utils import importutils
32import urllib.parse
33
34import glanceclient
35from glanceclient._i18n import _
36from glanceclient.common import utils
37from glanceclient import exc
38
39from keystoneauth1 import discover
40from keystoneauth1 import exceptions as ks_exc
41from keystoneauth1.identity import v2 as v2_auth
42from keystoneauth1.identity import v3 as v3_auth
43from keystoneauth1 import loading
44
45osprofiler_profiler = importutils.try_import("osprofiler.profiler")
46
47SUPPORTED_VERSIONS = [1, 2]
48
49
50class OpenStackImagesShell(object):
51
52    def _append_global_identity_args(self, parser, argv):
53        # register common identity args
54        parser.set_defaults(os_auth_url=utils.env('OS_AUTH_URL'))
55
56        parser.set_defaults(os_project_name=utils.env(
57            'OS_PROJECT_NAME', 'OS_TENANT_NAME'))
58        parser.set_defaults(os_project_id=utils.env(
59            'OS_PROJECT_ID', 'OS_TENANT_ID'))
60
61        parser.add_argument('--os_tenant_id',
62                            help=argparse.SUPPRESS)
63
64        parser.add_argument('--os_tenant_name',
65                            help=argparse.SUPPRESS)
66
67        parser.add_argument('--os-region-name',
68                            default=utils.env('OS_REGION_NAME'),
69                            help='Defaults to env[OS_REGION_NAME].')
70
71        parser.add_argument('--os_region_name',
72                            help=argparse.SUPPRESS)
73
74        parser.add_argument('--os-auth-token',
75                            default=utils.env('OS_AUTH_TOKEN'),
76                            help='Defaults to env[OS_AUTH_TOKEN].')
77
78        parser.add_argument('--os_auth_token',
79                            help=argparse.SUPPRESS)
80
81        parser.add_argument('--os-service-type',
82                            default=utils.env('OS_SERVICE_TYPE'),
83                            help='Defaults to env[OS_SERVICE_TYPE].')
84
85        parser.add_argument('--os_service_type',
86                            help=argparse.SUPPRESS)
87
88        parser.add_argument('--os-endpoint-type',
89                            default=utils.env('OS_ENDPOINT_TYPE'),
90                            help='Defaults to env[OS_ENDPOINT_TYPE].')
91
92        parser.add_argument('--os_endpoint_type',
93                            help=argparse.SUPPRESS)
94
95        loading.register_session_argparse_arguments(parser)
96        # Peek into argv to see if os-auth-token (or the deprecated
97        # os_auth_token) or the new os-token or the environment variable
98        # OS_AUTH_TOKEN were given. In which case, the token auth plugin is
99        # what the user wants. Else, we'll default to password.
100        default_auth_plugin = 'password'
101        token_opts = ['os-token', 'os-auth-token', 'os_auth-token']
102        if argv and any(i in token_opts for i in argv):
103            default_auth_plugin = 'token'
104        loading.register_auth_argparse_arguments(
105            parser, argv, default=default_auth_plugin)
106
107    def get_base_parser(self, argv):
108        parser = argparse.ArgumentParser(
109            prog='glance',
110            description=__doc__.strip(),
111            epilog='See "glance help COMMAND" '
112                   'for help on a specific command.',
113            add_help=False,
114            formatter_class=HelpFormatter,
115        )
116
117        # Global arguments
118        parser.add_argument('-h', '--help',
119                            action='store_true',
120                            help=argparse.SUPPRESS,
121                            )
122
123        parser.add_argument('--version',
124                            action='version',
125                            version=glanceclient.__version__)
126
127        parser.add_argument('-d', '--debug',
128                            default=bool(utils.env('GLANCECLIENT_DEBUG')),
129                            action='store_true',
130                            help='Defaults to env[GLANCECLIENT_DEBUG].')
131
132        parser.add_argument('-v', '--verbose',
133                            default=False, action="store_true",
134                            help="Print more verbose output.")
135
136        parser.add_argument('--get-schema',
137                            default=False, action="store_true",
138                            dest='get_schema',
139                            help='Ignores cached copy and forces retrieval '
140                                 'of schema that generates portions of the '
141                                 'help text. Ignored with API version 1.')
142
143        parser.add_argument('-f', '--force',
144                            dest='force',
145                            default=False, action='store_true',
146                            help='Prevent select actions from requesting '
147                            'user confirmation.')
148
149        parser.add_argument('--os-image-url',
150                            default=utils.env('OS_IMAGE_URL'),
151                            help=('Defaults to env[OS_IMAGE_URL]. '
152                                  'If the provided image url contains '
153                                  'a version number and '
154                                  '`--os-image-api-version` is omitted '
155                                  'the version of the URL will be picked as '
156                                  'the image api version to use.'))
157
158        parser.add_argument('--os_image_url',
159                            help=argparse.SUPPRESS)
160
161        parser.add_argument('--os-image-api-version',
162                            default=utils.env('OS_IMAGE_API_VERSION',
163                                              default=None),
164                            help='Defaults to env[OS_IMAGE_API_VERSION] or 2.')
165
166        parser.add_argument('--os_image_api_version',
167                            help=argparse.SUPPRESS)
168
169        parser.set_defaults(func=self.do_help)
170
171        if osprofiler_profiler:
172            parser.add_argument('--profile',
173                                metavar='HMAC_KEY',
174                                default=utils.env('OS_PROFILE'),
175                                help='HMAC key to use for encrypting context '
176                                'data for performance profiling of operation. '
177                                'This key should be the value of HMAC key '
178                                'configured in osprofiler middleware in '
179                                'glance, it is specified in glance '
180                                'configuration file at '
181                                '/usr/local/etc/glance/glance-api.conf and '
182                                '/usr/local/etc/glance/glance-registry.conf. Without '
183                                'key the profiling will not be triggered even '
184                                'if osprofiler is enabled on server side. '
185                                'Defaults to env[OS_PROFILE].')
186
187        self._append_global_identity_args(parser, argv)
188
189        return parser
190
191    def get_subcommand_parser(self, version, argv=None):
192        parser = self.get_base_parser(argv)
193
194        self.subcommands = {}
195        subparsers = parser.add_subparsers(metavar='<subcommand>')
196        submodule = importutils.import_versioned_module('glanceclient',
197                                                        version, 'shell')
198
199        self._find_actions(subparsers, submodule)
200        self._find_actions(subparsers, self)
201
202        self._add_bash_completion_subparser(subparsers)
203
204        return parser
205
206    def _find_actions(self, subparsers, actions_module):
207        for attr in (a for a in dir(actions_module) if a.startswith('do_')):
208            # Replace underscores with hyphens in the commands
209            # displayed to the user
210            command = attr[3:].replace('_', '-')
211            callback = getattr(actions_module, attr)
212            desc = callback.__doc__ or ''
213            help = desc.strip().split('\n')[0]
214            arguments = getattr(callback, 'arguments', [])
215
216            subparser = subparsers.add_parser(command,
217                                              help=help,
218                                              description=desc,
219                                              add_help=False,
220                                              formatter_class=HelpFormatter
221                                              )
222            subparser.add_argument('-h', '--help',
223                                   action='help',
224                                   help=argparse.SUPPRESS,
225                                   )
226            self.subcommands[command] = subparser
227            for (args, kwargs) in arguments:
228                subparser.add_argument(*args, **kwargs)
229            subparser.set_defaults(func=callback)
230
231    def _add_bash_completion_subparser(self, subparsers):
232        subparser = subparsers.add_parser('bash_completion',
233                                          add_help=False,
234                                          formatter_class=HelpFormatter)
235        self.subcommands['bash_completion'] = subparser
236        subparser.set_defaults(func=self.do_bash_completion)
237
238    def _get_image_url(self, args):
239        """Translate the available url-related options into a single string.
240
241        Return the endpoint that should be used to talk to Glance if a
242        clear decision can be made. Otherwise, return None.
243        """
244        if args.os_image_url:
245            return args.os_image_url
246        else:
247            return None
248
249    def _discover_auth_versions(self, session, auth_url):
250        # discover the API versions the server is supporting base on the
251        # given URL
252        v2_auth_url = None
253        v3_auth_url = None
254        try:
255            ks_discover = discover.Discover(session=session, url=auth_url)
256            v2_auth_url = ks_discover.url_for('2.0')
257            v3_auth_url = ks_discover.url_for('3.0')
258        except ks_exc.ClientException as e:
259            # Identity service may not support discover API version.
260            # Lets trying to figure out the API version from the original URL.
261            url_parts = urllib.parse.urlparse(auth_url)
262            (scheme, netloc, path, params, query, fragment) = url_parts
263            path = path.lower()
264            if path.startswith('/v3'):
265                v3_auth_url = auth_url
266            elif path.startswith('/v2'):
267                v2_auth_url = auth_url
268            else:
269                # not enough information to determine the auth version
270                msg = ('Unable to determine the Keystone version '
271                       'to authenticate with using the given '
272                       'auth_url. Identity service may not support API '
273                       'version discovery. Please provide a versioned '
274                       'auth_url instead. error=%s') % (e)
275                raise exc.CommandError(msg)
276
277        return (v2_auth_url, v3_auth_url)
278
279    def _get_keystone_auth_plugin(self, ks_session, **kwargs):
280        # discover the supported keystone versions using the given auth url
281        auth_url = kwargs.pop('auth_url', None)
282        (v2_auth_url, v3_auth_url) = self._discover_auth_versions(
283            session=ks_session,
284            auth_url=auth_url)
285
286        # Determine which authentication plugin to use. First inspect the
287        # auth_url to see the supported version. If both v3 and v2 are
288        # supported, then use the highest version if possible.
289        user_id = kwargs.pop('user_id', None)
290        username = kwargs.pop('username', None)
291        password = kwargs.pop('password', None)
292        user_domain_name = kwargs.pop('user_domain_name', None)
293        user_domain_id = kwargs.pop('user_domain_id', None)
294        # project and tenant can be used interchangeably
295        project_id = (kwargs.pop('project_id', None) or
296                      kwargs.pop('tenant_id', None))
297        project_name = (kwargs.pop('project_name', None) or
298                        kwargs.pop('tenant_name', None))
299        project_domain_id = kwargs.pop('project_domain_id', None)
300        project_domain_name = kwargs.pop('project_domain_name', None)
301        auth = None
302
303        use_domain = (user_domain_id or
304                      user_domain_name or
305                      project_domain_id or
306                      project_domain_name)
307        use_v3 = v3_auth_url and (use_domain or (not v2_auth_url))
308        use_v2 = v2_auth_url and not use_domain
309
310        if use_v3:
311            auth = v3_auth.Password(
312                v3_auth_url,
313                user_id=user_id,
314                username=username,
315                password=password,
316                user_domain_id=user_domain_id,
317                user_domain_name=user_domain_name,
318                project_id=project_id,
319                project_name=project_name,
320                project_domain_id=project_domain_id,
321                project_domain_name=project_domain_name)
322        elif use_v2:
323            auth = v2_auth.Password(
324                v2_auth_url,
325                username,
326                password,
327                tenant_id=project_id,
328                tenant_name=project_name)
329        else:
330            # if we get here it means domain information is provided
331            # (caller meant to use Keystone V3) but the auth url is
332            # actually Keystone V2. Obviously we can't authenticate a V3
333            # user using V2.
334            exc.CommandError("Credential and auth_url mismatch. The given "
335                             "auth_url is using Keystone V2 endpoint, which "
336                             "may not able to handle Keystone V3 credentials. "
337                             "Please provide a correct Keystone V3 auth_url.")
338
339        return auth
340
341    def _get_kwargs_to_create_auth_plugin(self, args):
342        if not args.os_username:
343            raise exc.CommandError(
344                _("You must provide a username via"
345                  " either --os-username or "
346                  "env[OS_USERNAME]"))
347
348        if not args.os_password:
349            # No password, If we've got a tty, try prompting for it
350            if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty():
351                # Check for Ctl-D
352                try:
353                    args.os_password = getpass.getpass('OS Password: ')
354                except EOFError:
355                    pass
356            # No password because we didn't have a tty or the
357            # user Ctl-D when prompted.
358            if not args.os_password:
359                raise exc.CommandError(
360                    _("You must provide a password via "
361                      "either --os-password, "
362                      "env[OS_PASSWORD], "
363                      "or prompted response"))
364
365        # Validate password flow auth
366        os_project_name = getattr(
367            args, 'os_project_name', getattr(args, 'os_tenant_name', None))
368        os_project_id = getattr(
369            args, 'os_project_id', getattr(args, 'os_tenant_id', None))
370        if not any([os_project_name, os_project_id]):
371            # tenant is deprecated in Keystone v3. Use the latest
372            # terminology instead.
373            raise exc.CommandError(
374                _("You must provide a project_id or project_name ("
375                  "with project_domain_name or project_domain_id) "
376                  "via "
377                  "  --os-project-id (env[OS_PROJECT_ID])"
378                  "  --os-project-name (env[OS_PROJECT_NAME]),"
379                  "  --os-project-domain-id "
380                  "(env[OS_PROJECT_DOMAIN_ID])"
381                  "  --os-project-domain-name "
382                  "(env[OS_PROJECT_DOMAIN_NAME])"))
383
384        if not args.os_auth_url:
385            raise exc.CommandError(
386                _("You must provide an auth url via"
387                  " either --os-auth-url or "
388                  "via env[OS_AUTH_URL]"))
389
390        kwargs = {
391            'auth_url': args.os_auth_url,
392            'username': args.os_username,
393            'user_id': args.os_user_id,
394            'user_domain_id': args.os_user_domain_id,
395            'user_domain_name': args.os_user_domain_name,
396            'password': args.os_password,
397            'tenant_name': args.os_tenant_name,
398            'tenant_id': args.os_tenant_id,
399            'project_name': args.os_project_name,
400            'project_id': args.os_project_id,
401            'project_domain_name': args.os_project_domain_name,
402            'project_domain_id': args.os_project_domain_id,
403        }
404        return kwargs
405
406    def _get_versioned_client(self, api_version, args):
407        endpoint = self._get_image_url(args)
408        auth_token = args.os_auth_token
409
410        if endpoint and auth_token:
411            kwargs = {
412                'token': auth_token,
413                'insecure': args.insecure,
414                'timeout': args.timeout,
415                'cacert': args.os_cacert,
416                'cert': args.os_cert,
417                'key': args.os_key,
418            }
419        else:
420            ks_session = loading.load_session_from_argparse_arguments(args)
421            auth_plugin_kwargs = self._get_kwargs_to_create_auth_plugin(args)
422            ks_session.auth = self._get_keystone_auth_plugin(
423                ks_session=ks_session, **auth_plugin_kwargs)
424            kwargs = {'session': ks_session}
425
426            if endpoint is None:
427                endpoint_type = args.os_endpoint_type or 'public'
428                service_type = args.os_service_type or 'image'
429                endpoint = ks_session.get_endpoint(
430                    service_type=service_type,
431                    interface=endpoint_type,
432                    region_name=args.os_region_name)
433
434        return glanceclient.Client(api_version, endpoint, **kwargs)
435
436    def _cache_schemas(self, options, client, home_dir='~/.glanceclient'):
437        homedir = os.path.expanduser(home_dir)
438        path_prefix = homedir
439        if options.os_auth_url:
440            hash_host = hashlib.sha1(options.os_auth_url.encode('utf-8'))
441            path_prefix = os.path.join(path_prefix, hash_host.hexdigest())
442        if not os.path.exists(path_prefix):
443            try:
444                os.makedirs(path_prefix)
445            except OSError as e:
446                # This avoids glanceclient to crash if it can't write to
447                # ~/.glanceclient, which may happen on some env (for me,
448                # it happens in Jenkins, as glanceclient can't write to
449                # /var/lib/jenkins).
450                msg = '%s' % e
451                print(encodeutils.safe_decode(msg), file=sys.stderr)
452        resources = ['image', 'metadefs/namespace', 'metadefs/resource_type']
453        schema_file_paths = [os.path.join(path_prefix, x + '_schema.json')
454                             for x in ['image', 'namespace', 'resource_type']]
455
456        failed_download_schema = 0
457        for resource, schema_file_path in zip(resources, schema_file_paths):
458            if (not os.path.exists(schema_file_path)) or options.get_schema:
459                try:
460                    schema = client.schemas.get(resource)
461                    with open(schema_file_path, 'w') as f:
462                        f.write(json.dumps(schema.raw()))
463                except exc.Unauthorized:
464                    raise exc.CommandError(
465                        "Invalid OpenStack Identity credentials.")
466                except Exception:
467                    # NOTE(esheffield) do nothing here, we'll get a message
468                    # later if the schema is missing
469                    failed_download_schema += 1
470                    pass
471
472        return failed_download_schema >= len(resources)
473
474    def main(self, argv):
475
476        def _get_subparser(api_version):
477            try:
478                return self.get_subcommand_parser(api_version, argv)
479            except ImportError as e:
480                if not str(e):
481                    # Add a generic import error message if the raised
482                    # ImportError has none.
483                    raise ImportError('Unable to import module. Re-run '
484                                      'with --debug for more info.')
485                raise
486
487        # Parse args once to find version
488
489        # NOTE(flepied) Under Python3, parsed arguments are removed
490        # from the list so make a copy for the first parsing
491        base_argv = copy.deepcopy(argv)
492        parser = self.get_base_parser(argv)
493        (options, args) = parser.parse_known_args(base_argv)
494
495        try:
496            # NOTE(flaper87): Try to get the version from the
497            # image-url first. If no version was specified, fallback
498            # to the api-image-version arg. If both of these fail then
499            # fallback to the minimum supported one and let keystone
500            # do the magic.
501            endpoint = self._get_image_url(options)
502            endpoint, url_version = utils.strip_version(endpoint)
503        except ValueError:
504            # NOTE(flaper87): ValueError is raised if no endpoint is provided
505            url_version = None
506
507        # build available subcommands based on version
508        try:
509            api_version = int(options.os_image_api_version or url_version or 2)
510            if api_version not in SUPPORTED_VERSIONS:
511                raise ValueError
512        except ValueError:
513            msg = ("Invalid API version parameter. "
514                   "Supported values are %s" % SUPPORTED_VERSIONS)
515            utils.exit(msg=msg)
516
517        # Handle top-level --help/-h before attempting to parse
518        # a command off the command line
519        if options.help or not argv:
520            parser = _get_subparser(api_version)
521            self.do_help(options, parser=parser)
522            return 0
523
524        # short-circuit and deal with help command right away.
525        sub_parser = _get_subparser(api_version)
526        args = sub_parser.parse_args(argv)
527
528        if args.func == self.do_help:
529            self.do_help(args, parser=sub_parser)
530            return 0
531        elif args.func == self.do_bash_completion:
532            self.do_bash_completion(args)
533            return 0
534
535        if not options.os_image_api_version and api_version == 2:
536            switch_version = True
537            client = self._get_versioned_client('2', args)
538
539            resp, body = client.http_client.get('/versions')
540
541            for version in body['versions']:
542                if version['id'].startswith('v2'):
543                    # NOTE(flaper87): We know v2 is enabled in the server,
544                    # which means we should be able to get the schemas and
545                    # move on.
546                    switch_version = self._cache_schemas(options, client)
547                    break
548
549            if switch_version:
550                print('WARNING: The client is falling back to v1 because'
551                      ' the accessing to v2 failed. This behavior will'
552                      ' be removed in future versions', file=sys.stderr)
553                api_version = 1
554
555        sub_parser = _get_subparser(api_version)
556
557        # Parse args again and call whatever callback was selected
558        args = sub_parser.parse_args(argv)
559
560        # NOTE(flaper87): Make sure we re-use the password input if we
561        # have one. This may happen if the schemas were downloaded in
562        # this same command. Password will be asked to download the
563        # schemas and then for the operations below.
564        if not args.os_password and options.os_password:
565            args.os_password = options.os_password
566
567        if args.debug:
568            # Set up the root logger to debug so that the submodules can
569            # print debug messages
570            logging.basicConfig(level=logging.DEBUG)
571            # for iso8601 < 0.1.11
572            logging.getLogger('iso8601').setLevel(logging.WARNING)
573        LOG = logging.getLogger('glanceclient')
574        LOG.addHandler(logging.StreamHandler())
575        LOG.setLevel(logging.DEBUG if args.debug else logging.INFO)
576
577        profile = osprofiler_profiler and options.profile
578        if profile:
579            osprofiler_profiler.init(options.profile)
580
581        client = self._get_versioned_client(api_version, args)
582
583        try:
584            args.func(client, args)
585        except exc.Unauthorized:
586            raise exc.CommandError("Invalid OpenStack Identity credentials.")
587        finally:
588            if profile:
589                trace_id = osprofiler_profiler.get().get_base_id()
590                print("Profiling trace ID: %s" % trace_id)
591                print("To display trace use next command:\n"
592                      "osprofiler trace show --html %s " % trace_id)
593
594    @utils.arg('command', metavar='<subcommand>', nargs='?',
595               help='Display help for <subcommand>.')
596    def do_help(self, args, parser):
597        """Display help about this program or one of its subcommands."""
598        command = getattr(args, 'command', '')
599
600        if command:
601            if args.command in self.subcommands:
602                self.subcommands[args.command].print_help()
603            else:
604                raise exc.CommandError("'%s' is not a valid subcommand" %
605                                       args.command)
606        else:
607            parser.print_help()
608
609        if not args.os_image_api_version or args.os_image_api_version == '2':
610            # NOTE(NiallBunting) This currently assumes that the only versions
611            # are one and two.
612            try:
613                if command is None:
614                    print("\nRun `glance --os-image-api-version 1 help`"
615                          " for v1 help")
616                else:
617                    self.get_subcommand_parser(1)
618                    if command in self.subcommands:
619                        command = ' ' + command
620                        print(("\nRun `glance --os-image-api-version 1 help%s`"
621                               " for v1 help") % (command or ''))
622            except ImportError:
623                pass
624
625    def do_bash_completion(self, _args):
626        """Prints arguments for bash_completion.
627
628        Prints all of the commands and options to stdout so that the
629        glance.bash_completion script doesn't have to hard code them.
630        """
631        commands = set()
632        options = set()
633        for sc_str, sc in self.subcommands.items():
634            commands.add(sc_str)
635            for option in sc._optionals._option_string_actions.keys():
636                options.add(option)
637
638        commands.remove('bash_completion')
639        commands.remove('bash-completion')
640        print(' '.join(commands | options))
641
642
643class HelpFormatter(argparse.HelpFormatter):
644    def start_section(self, heading):
645        # Title-case the headings
646        heading = '%s%s' % (heading[0].upper(), heading[1:])
647        super(HelpFormatter, self).start_section(heading)
648
649
650def main():
651    try:
652        argv = [encodeutils.safe_decode(a) for a in sys.argv[1:]]
653        OpenStackImagesShell().main(argv)
654    except KeyboardInterrupt:
655        utils.exit('... terminating glance client', exit_code=130)
656    except Exception as e:
657        if utils.debug_enabled(argv) is True:
658            traceback.print_exc()
659        utils.exit(encodeutils.exception_to_unicode(e))
660