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