1# Copyright 2012-2013 OpenStack Foundation 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may 4# not use this file except in compliance with the License. You may obtain 5# a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations 13# under the License. 14# 15 16"""Image V2 Action Implementations""" 17 18import argparse 19from base64 import b64encode 20import logging 21import os 22import sys 23 24import openstack.cloud._utils 25from openstack.image import image_signer 26from osc_lib.api import utils as api_utils 27from osc_lib.cli import format_columns 28from osc_lib.cli import parseractions 29from osc_lib.command import command 30from osc_lib import exceptions 31from osc_lib import utils 32 33from openstackclient.common import sdk_utils 34from openstackclient.i18n import _ 35from openstackclient.identity import common 36 37if os.name == "nt": 38 import msvcrt 39else: 40 msvcrt = None 41 42 43CONTAINER_CHOICES = ["ami", "ari", "aki", "bare", "docker", "ova", "ovf"] 44DEFAULT_CONTAINER_FORMAT = 'bare' 45DEFAULT_DISK_FORMAT = 'raw' 46DISK_CHOICES = ["ami", "ari", "aki", "vhd", "vmdk", "raw", "qcow2", "vhdx", 47 "vdi", "iso", "ploop"] 48MEMBER_STATUS_CHOICES = ["accepted", "pending", "rejected", "all"] 49 50 51LOG = logging.getLogger(__name__) 52 53 54def _format_image(image, human_readable=False): 55 """Format an image to make it more consistent with OSC operations.""" 56 57 info = {} 58 properties = {} 59 60 # the only fields we're not including is "links", "tags" and the properties 61 fields_to_show = ['status', 'name', 'container_format', 'created_at', 62 'size', 'disk_format', 'updated_at', 'visibility', 63 'min_disk', 'protected', 'id', 'file', 'checksum', 64 'owner', 'virtual_size', 'min_ram', 'schema'] 65 66 # TODO(gtema/anybody): actually it should be possible to drop this method, 67 # since SDK already delivers a proper object 68 image = image.to_dict(ignore_none=True, original_names=True) 69 70 # split out the usual key and the properties which are top-level 71 for key in image: 72 if key in fields_to_show: 73 info[key] = image.get(key) 74 elif key == 'tags': 75 continue # handle this later 76 elif key == 'properties': 77 # NOTE(gtema): flatten content of properties 78 properties.update(image.get(key)) 79 elif key != 'location': 80 properties[key] = image.get(key) 81 82 if human_readable: 83 info['size'] = utils.format_size(image['size']) 84 85 # format the tags if they are there 86 info['tags'] = format_columns.ListColumn(image.get('tags')) 87 88 # add properties back into the dictionary as a top-level key 89 if properties: 90 info['properties'] = format_columns.DictColumn(properties) 91 92 return info 93 94 95_formatters = { 96 'tags': format_columns.ListColumn, 97} 98 99 100def _get_member_columns(item): 101 # Trick sdk_utils to return URI attribute 102 column_map = { 103 'image_id': 'image_id' 104 } 105 hidden_columns = ['id', 'location', 'name'] 106 return sdk_utils.get_osc_show_columns_for_sdk_resource( 107 item.to_dict(), column_map, hidden_columns) 108 109 110def get_data_file(args): 111 if args.file: 112 return (open(args.file, 'rb'), args.file) 113 else: 114 # distinguish cases where: 115 # (1) stdin is not valid (as in cron jobs): 116 # openstack ... <&- 117 # (2) image data is provided through stdin: 118 # openstack ... < /tmp/file 119 # (3) no image data provided 120 # openstack ... 121 try: 122 os.fstat(0) 123 except OSError: 124 # (1) stdin is not valid 125 return (None, None) 126 if not sys.stdin.isatty(): 127 # (2) image data is provided through stdin 128 image = sys.stdin 129 if hasattr(sys.stdin, 'buffer'): 130 image = sys.stdin.buffer 131 if msvcrt: 132 msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) 133 134 return (image, None) 135 else: 136 # (3) 137 return (None, None) 138 139 140class AddProjectToImage(command.ShowOne): 141 _description = _("Associate project with image") 142 143 def get_parser(self, prog_name): 144 parser = super(AddProjectToImage, self).get_parser(prog_name) 145 parser.add_argument( 146 "image", 147 metavar="<image>", 148 help=_("Image to share (name or ID)"), 149 ) 150 parser.add_argument( 151 "project", 152 metavar="<project>", 153 help=_("Project to associate with image (ID)"), 154 ) 155 common.add_project_domain_option_to_parser(parser) 156 return parser 157 158 def take_action(self, parsed_args): 159 image_client = self.app.client_manager.image 160 identity_client = self.app.client_manager.identity 161 162 if openstack.cloud._utils._is_uuid_like(parsed_args.project): 163 project_id = parsed_args.project 164 else: 165 project_id = common.find_project( 166 identity_client, 167 parsed_args.project, 168 parsed_args.project_domain).id 169 170 image = image_client.find_image(parsed_args.image, 171 ignore_missing=False) 172 173 obj = image_client.add_member( 174 image=image.id, 175 member_id=project_id, 176 ) 177 178 display_columns, columns = _get_member_columns(obj) 179 data = utils.get_item_properties(obj, columns, formatters={}) 180 181 return (display_columns, data) 182 183 184class CreateImage(command.ShowOne): 185 _description = _("Create/upload an image") 186 187 deadopts = ('size', 'location', 'copy-from', 'checksum', 'store') 188 189 def get_parser(self, prog_name): 190 parser = super(CreateImage, self).get_parser(prog_name) 191 # TODO(bunting): There are additional arguments that v1 supported 192 # that v2 either doesn't support or supports weirdly. 193 # --checksum - could be faked clientside perhaps? 194 # --location - maybe location add? 195 # --size - passing image size is actually broken in python-glanceclient 196 # --copy-from - does not exist in v2 197 # --store - does not exits in v2 198 parser.add_argument( 199 "name", 200 metavar="<image-name>", 201 help=_("New image name"), 202 ) 203 parser.add_argument( 204 "--id", 205 metavar="<id>", 206 help=_("Image ID to reserve"), 207 ) 208 parser.add_argument( 209 "--container-format", 210 default=DEFAULT_CONTAINER_FORMAT, 211 choices=CONTAINER_CHOICES, 212 metavar="<container-format>", 213 help=(_("Image container format. " 214 "The supported options are: %(option_list)s. " 215 "The default format is: %(default_opt)s") % 216 {'option_list': ', '.join(CONTAINER_CHOICES), 217 'default_opt': DEFAULT_CONTAINER_FORMAT}) 218 ) 219 parser.add_argument( 220 "--disk-format", 221 default=DEFAULT_DISK_FORMAT, 222 choices=DISK_CHOICES, 223 metavar="<disk-format>", 224 help=_("Image disk format. The supported options are: %s. " 225 "The default format is: raw") % ', '.join(DISK_CHOICES) 226 ) 227 parser.add_argument( 228 "--min-disk", 229 metavar="<disk-gb>", 230 type=int, 231 help=_("Minimum disk size needed to boot image, in gigabytes"), 232 ) 233 parser.add_argument( 234 "--min-ram", 235 metavar="<ram-mb>", 236 type=int, 237 help=_("Minimum RAM size needed to boot image, in megabytes"), 238 ) 239 source_group = parser.add_mutually_exclusive_group() 240 source_group.add_argument( 241 "--file", 242 metavar="<file>", 243 help=_("Upload image from local file"), 244 ) 245 source_group.add_argument( 246 "--volume", 247 metavar="<volume>", 248 help=_("Create image from a volume"), 249 ) 250 parser.add_argument( 251 "--force", 252 dest='force', 253 action='store_true', 254 default=False, 255 help=_("Force image creation if volume is in use " 256 "(only meaningful with --volume)"), 257 ) 258 parser.add_argument( 259 '--sign-key-path', 260 metavar="<sign-key-path>", 261 default=[], 262 help=_("Sign the image using the specified private key. " 263 "Only use in combination with --sign-cert-id") 264 ) 265 parser.add_argument( 266 '--sign-cert-id', 267 metavar="<sign-cert-id>", 268 default=[], 269 help=_("The specified certificate UUID is a reference to " 270 "the certificate in the key manager that corresponds " 271 "to the public key and is used for signature validation. " 272 "Only use in combination with --sign-key-path") 273 ) 274 protected_group = parser.add_mutually_exclusive_group() 275 protected_group.add_argument( 276 "--protected", 277 action="store_true", 278 help=_("Prevent image from being deleted"), 279 ) 280 protected_group.add_argument( 281 "--unprotected", 282 action="store_true", 283 help=_("Allow image to be deleted (default)"), 284 ) 285 public_group = parser.add_mutually_exclusive_group() 286 public_group.add_argument( 287 "--public", 288 action="store_true", 289 help=_("Image is accessible to the public"), 290 ) 291 public_group.add_argument( 292 "--private", 293 action="store_true", 294 help=_("Image is inaccessible to the public (default)"), 295 ) 296 public_group.add_argument( 297 "--community", 298 action="store_true", 299 help=_("Image is accessible to the community"), 300 ) 301 public_group.add_argument( 302 "--shared", 303 action="store_true", 304 help=_("Image can be shared"), 305 ) 306 parser.add_argument( 307 "--property", 308 dest="properties", 309 metavar="<key=value>", 310 action=parseractions.KeyValueAction, 311 help=_("Set a property on this image " 312 "(repeat option to set multiple properties)"), 313 ) 314 parser.add_argument( 315 "--tag", 316 dest="tags", 317 metavar="<tag>", 318 action='append', 319 help=_("Set a tag on this image " 320 "(repeat option to set multiple tags)"), 321 ) 322 parser.add_argument( 323 "--project", 324 metavar="<project>", 325 help=_("Set an alternate project on this image (name or ID)"), 326 ) 327 common.add_project_domain_option_to_parser(parser) 328 for deadopt in self.deadopts: 329 parser.add_argument( 330 "--%s" % deadopt, 331 metavar="<%s>" % deadopt, 332 dest=deadopt.replace('-', '_'), 333 help=argparse.SUPPRESS, 334 ) 335 return parser 336 337 def take_action(self, parsed_args): 338 identity_client = self.app.client_manager.identity 339 image_client = self.app.client_manager.image 340 341 for deadopt in self.deadopts: 342 if getattr(parsed_args, deadopt.replace('-', '_'), None): 343 raise exceptions.CommandError( 344 _("ERROR: --%s was given, which is an Image v1 option" 345 " that is no longer supported in Image v2") % deadopt) 346 347 # Build an attribute dict from the parsed args, only include 348 # attributes that were actually set on the command line 349 kwargs = {} 350 copy_attrs = ('name', 'id', 351 'container_format', 'disk_format', 352 'min_disk', 'min_ram', 'tags', 'visibility') 353 for attr in copy_attrs: 354 if attr in parsed_args: 355 val = getattr(parsed_args, attr, None) 356 if val: 357 # Only include a value in kwargs for attributes that 358 # are actually present on the command line 359 kwargs[attr] = val 360 361 # properties should get flattened into the general kwargs 362 if getattr(parsed_args, 'properties', None): 363 for k, v in parsed_args.properties.items(): 364 kwargs[k] = str(v) 365 366 # Handle exclusive booleans with care 367 # Avoid including attributes in kwargs if an option is not 368 # present on the command line. These exclusive booleans are not 369 # a single value for the pair of options because the default must be 370 # to do nothing when no options are present as opposed to always 371 # setting a default. 372 if parsed_args.protected: 373 kwargs['is_protected'] = True 374 if parsed_args.unprotected: 375 kwargs['is_protected'] = False 376 if parsed_args.public: 377 kwargs['visibility'] = 'public' 378 if parsed_args.private: 379 kwargs['visibility'] = 'private' 380 if parsed_args.community: 381 kwargs['visibility'] = 'community' 382 if parsed_args.shared: 383 kwargs['visibility'] = 'shared' 384 if parsed_args.project: 385 kwargs['owner_id'] = common.find_project( 386 identity_client, 387 parsed_args.project, 388 parsed_args.project_domain, 389 ).id 390 391 # open the file first to ensure any failures are handled before the 392 # image is created. Get the file name (if it is file, and not stdin) 393 # for easier further handling. 394 (fp, fname) = get_data_file(parsed_args) 395 info = {} 396 397 if fp is not None and parsed_args.volume: 398 raise exceptions.CommandError(_("Uploading data and using " 399 "container are not allowed at " 400 "the same time")) 401 if fp is None and parsed_args.file: 402 LOG.warning(_("Failed to get an image file.")) 403 return {}, {} 404 elif fname: 405 kwargs['filename'] = fname 406 elif fp: 407 kwargs['validate_checksum'] = False 408 kwargs['data'] = fp 409 410 # sign an image using a given local private key file 411 if parsed_args.sign_key_path or parsed_args.sign_cert_id: 412 if not parsed_args.file: 413 msg = (_("signing an image requires the --file option, " 414 "passing files via stdin when signing is not " 415 "supported.")) 416 raise exceptions.CommandError(msg) 417 if (len(parsed_args.sign_key_path) < 1 or 418 len(parsed_args.sign_cert_id) < 1): 419 msg = (_("'sign-key-path' and 'sign-cert-id' must both be " 420 "specified when attempting to sign an image.")) 421 raise exceptions.CommandError(msg) 422 else: 423 sign_key_path = parsed_args.sign_key_path 424 sign_cert_id = parsed_args.sign_cert_id 425 signer = image_signer.ImageSigner() 426 try: 427 pw = utils.get_password( 428 self.app.stdin, 429 prompt=("Please enter private key password, leave " 430 "empty if none: "), 431 confirm=False) 432 if not pw or len(pw) < 1: 433 pw = None 434 signer.load_private_key( 435 sign_key_path, 436 password=pw) 437 except Exception: 438 msg = (_("Error during sign operation: private key " 439 "could not be loaded.")) 440 raise exceptions.CommandError(msg) 441 442 signature = signer.generate_signature(fp) 443 signature_b64 = b64encode(signature) 444 kwargs['img_signature'] = signature_b64 445 kwargs['img_signature_certificate_uuid'] = sign_cert_id 446 kwargs['img_signature_hash_method'] = signer.hash_method 447 if signer.padding_method: 448 kwargs['img_signature_key_type'] = \ 449 signer.padding_method 450 451 # If a volume is specified. 452 if parsed_args.volume: 453 volume_client = self.app.client_manager.volume 454 source_volume = utils.find_resource( 455 volume_client.volumes, 456 parsed_args.volume, 457 ) 458 response, body = volume_client.volumes.upload_to_image( 459 source_volume.id, 460 parsed_args.force, 461 parsed_args.name, 462 parsed_args.container_format, 463 parsed_args.disk_format, 464 ) 465 info = body['os-volume_upload_image'] 466 try: 467 info['volume_type'] = info['volume_type']['name'] 468 except TypeError: 469 info['volume_type'] = None 470 else: 471 image = image_client.create_image(**kwargs) 472 473 if not info: 474 info = _format_image(image) 475 476 return zip(*sorted(info.items())) 477 478 479class DeleteImage(command.Command): 480 _description = _("Delete image(s)") 481 482 def get_parser(self, prog_name): 483 parser = super(DeleteImage, self).get_parser(prog_name) 484 parser.add_argument( 485 "images", 486 metavar="<image>", 487 nargs="+", 488 help=_("Image(s) to delete (name or ID)"), 489 ) 490 return parser 491 492 def take_action(self, parsed_args): 493 494 del_result = 0 495 image_client = self.app.client_manager.image 496 for image in parsed_args.images: 497 try: 498 image_obj = image_client.find_image(image, 499 ignore_missing=False) 500 image_client.delete_image(image_obj.id) 501 except Exception as e: 502 del_result += 1 503 LOG.error(_("Failed to delete image with name or " 504 "ID '%(image)s': %(e)s"), 505 {'image': image, 'e': e}) 506 507 total = len(parsed_args.images) 508 if (del_result > 0): 509 msg = (_("Failed to delete %(dresult)s of %(total)s images.") 510 % {'dresult': del_result, 'total': total}) 511 raise exceptions.CommandError(msg) 512 513 514class ListImage(command.Lister): 515 _description = _("List available images") 516 517 def get_parser(self, prog_name): 518 parser = super(ListImage, self).get_parser(prog_name) 519 public_group = parser.add_mutually_exclusive_group() 520 public_group.add_argument( 521 "--public", 522 dest="public", 523 action="store_true", 524 default=False, 525 help=_("List only public images"), 526 ) 527 public_group.add_argument( 528 "--private", 529 dest="private", 530 action="store_true", 531 default=False, 532 help=_("List only private images"), 533 ) 534 public_group.add_argument( 535 "--community", 536 dest="community", 537 action="store_true", 538 default=False, 539 help=_("List only community images"), 540 ) 541 public_group.add_argument( 542 "--shared", 543 dest="shared", 544 action="store_true", 545 default=False, 546 help=_("List only shared images"), 547 ) 548 parser.add_argument( 549 '--property', 550 metavar='<key=value>', 551 action=parseractions.KeyValueAction, 552 help=_('Filter output based on property ' 553 '(repeat option to filter on multiple properties)'), 554 ) 555 parser.add_argument( 556 '--name', 557 metavar='<name>', 558 default=None, 559 help=_("Filter images based on name.") 560 ) 561 parser.add_argument( 562 '--status', 563 metavar='<status>', 564 default=None, 565 help=_("Filter images based on status.") 566 ) 567 parser.add_argument( 568 '--member-status', 569 metavar='<member-status>', 570 default=None, 571 type=lambda s: s.lower(), 572 choices=MEMBER_STATUS_CHOICES, 573 help=(_("Filter images based on member status. " 574 "The supported options are: %s. ") % 575 ', '.join(MEMBER_STATUS_CHOICES)) 576 ) 577 parser.add_argument( 578 '--tag', 579 metavar='<tag>', 580 default=None, 581 help=_('Filter images based on tag.'), 582 ) 583 parser.add_argument( 584 '--long', 585 action='store_true', 586 default=False, 587 help=_('List additional fields in output'), 588 ) 589 590 # --page-size has never worked, leave here for silent compatibility 591 # We'll implement limit/marker differently later 592 parser.add_argument( 593 "--page-size", 594 metavar="<size>", 595 help=argparse.SUPPRESS, 596 ) 597 parser.add_argument( 598 '--sort', 599 metavar="<key>[:<direction>]", 600 default='name:asc', 601 help=_("Sort output by selected keys and directions(asc or desc) " 602 "(default: name:asc), multiple keys and directions can be " 603 "specified separated by comma"), 604 ) 605 parser.add_argument( 606 "--limit", 607 metavar="<num-images>", 608 type=int, 609 help=_("Maximum number of images to display."), 610 ) 611 parser.add_argument( 612 '--marker', 613 metavar='<image>', 614 default=None, 615 help=_("The last image of the previous page. Display " 616 "list of images after marker. Display all images if not " 617 "specified. (name or ID)"), 618 ) 619 return parser 620 621 def take_action(self, parsed_args): 622 image_client = self.app.client_manager.image 623 624 kwargs = {} 625 if parsed_args.public: 626 kwargs['visibility'] = 'public' 627 if parsed_args.private: 628 kwargs['visibility'] = 'private' 629 if parsed_args.community: 630 kwargs['visibility'] = 'community' 631 if parsed_args.shared: 632 kwargs['visibility'] = 'shared' 633 if parsed_args.limit: 634 kwargs['limit'] = parsed_args.limit 635 if parsed_args.marker: 636 kwargs['marker'] = image_client.find_image(parsed_args.marker).id 637 if parsed_args.name: 638 kwargs['name'] = parsed_args.name 639 if parsed_args.status: 640 kwargs['status'] = parsed_args.status 641 if parsed_args.member_status: 642 kwargs['member_status'] = parsed_args.member_status 643 if parsed_args.tag: 644 kwargs['tag'] = parsed_args.tag 645 if parsed_args.long: 646 columns = ( 647 'ID', 648 'Name', 649 'Disk Format', 650 'Container Format', 651 'Size', 652 'Checksum', 653 'Status', 654 'visibility', 655 'is_protected', 656 'owner_id', 657 'tags', 658 ) 659 column_headers = ( 660 'ID', 661 'Name', 662 'Disk Format', 663 'Container Format', 664 'Size', 665 'Checksum', 666 'Status', 667 'Visibility', 668 'Protected', 669 'Project', 670 'Tags', 671 ) 672 else: 673 columns = ("ID", "Name", "Status") 674 column_headers = columns 675 676 # List of image data received 677 if 'limit' in kwargs: 678 # Disable automatic pagination in SDK 679 kwargs['paginated'] = False 680 data = list(image_client.images(**kwargs)) 681 682 if parsed_args.property: 683 for attr, value in parsed_args.property.items(): 684 api_utils.simple_filter( 685 data, 686 attr=attr, 687 value=value, 688 property_field='properties', 689 ) 690 691 data = utils.sort_items(data, parsed_args.sort, str) 692 693 return ( 694 column_headers, 695 (utils.get_item_properties( 696 s, 697 columns, 698 formatters=_formatters, 699 ) for s in data) 700 ) 701 702 703class ListImageProjects(command.Lister): 704 _description = _("List projects associated with image") 705 706 def get_parser(self, prog_name): 707 parser = super(ListImageProjects, self).get_parser(prog_name) 708 parser.add_argument( 709 "image", 710 metavar="<image>", 711 help=_("Image (name or ID)"), 712 ) 713 common.add_project_domain_option_to_parser(parser) 714 return parser 715 716 def take_action(self, parsed_args): 717 image_client = self.app.client_manager.image 718 columns = ( 719 "Image ID", 720 "Member ID", 721 "Status" 722 ) 723 724 image_id = image_client.find_image(parsed_args.image).id 725 726 data = image_client.members(image=image_id) 727 728 return (columns, 729 (utils.get_item_properties( 730 s, columns, 731 ) for s in data)) 732 733 734class RemoveProjectImage(command.Command): 735 _description = _("Disassociate project with image") 736 737 def get_parser(self, prog_name): 738 parser = super(RemoveProjectImage, self).get_parser(prog_name) 739 parser.add_argument( 740 "image", 741 metavar="<image>", 742 help=_("Image to unshare (name or ID)"), 743 ) 744 parser.add_argument( 745 "project", 746 metavar="<project>", 747 help=_("Project to disassociate with image (name or ID)"), 748 ) 749 common.add_project_domain_option_to_parser(parser) 750 return parser 751 752 def take_action(self, parsed_args): 753 image_client = self.app.client_manager.image 754 identity_client = self.app.client_manager.identity 755 756 project_id = common.find_project(identity_client, 757 parsed_args.project, 758 parsed_args.project_domain).id 759 760 image = image_client.find_image(parsed_args.image, 761 ignore_missing=False) 762 763 image_client.remove_member( 764 member=project_id, 765 image=image.id) 766 767 768class SaveImage(command.Command): 769 _description = _("Save an image locally") 770 771 def get_parser(self, prog_name): 772 parser = super(SaveImage, self).get_parser(prog_name) 773 parser.add_argument( 774 "--file", 775 metavar="<filename>", 776 help=_("Downloaded image save filename (default: stdout)"), 777 ) 778 parser.add_argument( 779 "image", 780 metavar="<image>", 781 help=_("Image to save (name or ID)"), 782 ) 783 return parser 784 785 def take_action(self, parsed_args): 786 image_client = self.app.client_manager.image 787 image = image_client.find_image(parsed_args.image) 788 789 image_client.download_image(image.id, output=parsed_args.file) 790 791 792class SetImage(command.Command): 793 _description = _("Set image properties") 794 795 deadopts = ('visibility',) 796 797 def get_parser(self, prog_name): 798 parser = super(SetImage, self).get_parser(prog_name) 799 # TODO(bunting): There are additional arguments that v1 supported 800 # --size - does not exist in v2 801 # --store - does not exist in v2 802 # --location - maybe location add? 803 # --copy-from - does not exist in v2 804 # --file - should be able to upload file 805 # --volume - not possible with v2 as can't change id 806 # --force - see `--volume` 807 # --checksum - maybe could be done client side 808 # --stdin - could be implemented 809 parser.add_argument( 810 "image", 811 metavar="<image>", 812 help=_("Image to modify (name or ID)") 813 ) 814 parser.add_argument( 815 "--name", 816 metavar="<name>", 817 help=_("New image name") 818 ) 819 parser.add_argument( 820 "--min-disk", 821 type=int, 822 metavar="<disk-gb>", 823 help=_("Minimum disk size needed to boot image, in gigabytes") 824 ) 825 parser.add_argument( 826 "--min-ram", 827 type=int, 828 metavar="<ram-mb>", 829 help=_("Minimum RAM size needed to boot image, in megabytes"), 830 ) 831 parser.add_argument( 832 "--container-format", 833 metavar="<container-format>", 834 choices=CONTAINER_CHOICES, 835 help=_("Image container format. The supported options are: %s") % 836 ', '.join(CONTAINER_CHOICES) 837 ) 838 parser.add_argument( 839 "--disk-format", 840 metavar="<disk-format>", 841 choices=DISK_CHOICES, 842 help=_("Image disk format. The supported options are: %s") % 843 ', '.join(DISK_CHOICES) 844 ) 845 protected_group = parser.add_mutually_exclusive_group() 846 protected_group.add_argument( 847 "--protected", 848 action="store_true", 849 help=_("Prevent image from being deleted"), 850 ) 851 protected_group.add_argument( 852 "--unprotected", 853 action="store_true", 854 help=_("Allow image to be deleted (default)"), 855 ) 856 public_group = parser.add_mutually_exclusive_group() 857 public_group.add_argument( 858 "--public", 859 action="store_true", 860 help=_("Image is accessible to the public"), 861 ) 862 public_group.add_argument( 863 "--private", 864 action="store_true", 865 help=_("Image is inaccessible to the public (default)"), 866 ) 867 public_group.add_argument( 868 "--community", 869 action="store_true", 870 help=_("Image is accessible to the community"), 871 ) 872 public_group.add_argument( 873 "--shared", 874 action="store_true", 875 help=_("Image can be shared"), 876 ) 877 parser.add_argument( 878 "--property", 879 dest="properties", 880 metavar="<key=value>", 881 action=parseractions.KeyValueAction, 882 help=_("Set a property on this image " 883 "(repeat option to set multiple properties)"), 884 ) 885 parser.add_argument( 886 "--tag", 887 dest="tags", 888 metavar="<tag>", 889 default=None, 890 action='append', 891 help=_("Set a tag on this image " 892 "(repeat option to set multiple tags)"), 893 ) 894 parser.add_argument( 895 "--architecture", 896 metavar="<architecture>", 897 help=_("Operating system architecture"), 898 ) 899 parser.add_argument( 900 "--instance-id", 901 metavar="<instance-id>", 902 help=_("ID of server instance used to create this image"), 903 ) 904 parser.add_argument( 905 "--instance-uuid", 906 metavar="<instance-id>", 907 dest="instance_id", 908 help=argparse.SUPPRESS, 909 ) 910 parser.add_argument( 911 "--kernel-id", 912 metavar="<kernel-id>", 913 help=_("ID of kernel image used to boot this disk image"), 914 ) 915 parser.add_argument( 916 "--os-distro", 917 metavar="<os-distro>", 918 help=_("Operating system distribution name"), 919 ) 920 parser.add_argument( 921 "--os-version", 922 metavar="<os-version>", 923 help=_("Operating system distribution version"), 924 ) 925 parser.add_argument( 926 "--ramdisk-id", 927 metavar="<ramdisk-id>", 928 help=_("ID of ramdisk image used to boot this disk image"), 929 ) 930 deactivate_group = parser.add_mutually_exclusive_group() 931 deactivate_group.add_argument( 932 "--deactivate", 933 action="store_true", 934 help=_("Deactivate the image"), 935 ) 936 deactivate_group.add_argument( 937 "--activate", 938 action="store_true", 939 help=_("Activate the image"), 940 ) 941 parser.add_argument( 942 "--project", 943 metavar="<project>", 944 help=_("Set an alternate project on this image (name or ID)"), 945 ) 946 common.add_project_domain_option_to_parser(parser) 947 for deadopt in self.deadopts: 948 parser.add_argument( 949 "--%s" % deadopt, 950 metavar="<%s>" % deadopt, 951 dest=deadopt.replace('-', '_'), 952 help=argparse.SUPPRESS, 953 ) 954 955 membership_group = parser.add_mutually_exclusive_group() 956 membership_group.add_argument( 957 "--accept", 958 action="store_true", 959 help=_("Accept the image membership"), 960 ) 961 membership_group.add_argument( 962 "--reject", 963 action="store_true", 964 help=_("Reject the image membership"), 965 ) 966 membership_group.add_argument( 967 "--pending", 968 action="store_true", 969 help=_("Reset the image membership to 'pending'"), 970 ) 971 return parser 972 973 def take_action(self, parsed_args): 974 identity_client = self.app.client_manager.identity 975 image_client = self.app.client_manager.image 976 977 for deadopt in self.deadopts: 978 if getattr(parsed_args, deadopt.replace('-', '_'), None): 979 raise exceptions.CommandError( 980 _("ERROR: --%s was given, which is an Image v1 option" 981 " that is no longer supported in Image v2") % deadopt) 982 983 kwargs = {} 984 copy_attrs = ('architecture', 'container_format', 'disk_format', 985 'file', 'instance_id', 'kernel_id', 'locations', 986 'min_disk', 'min_ram', 'name', 'os_distro', 'os_version', 987 'prefix', 'progress', 'ramdisk_id', 'tags', 'visibility') 988 for attr in copy_attrs: 989 if attr in parsed_args: 990 val = getattr(parsed_args, attr, None) 991 if val is not None: 992 # Only include a value in kwargs for attributes that are 993 # actually present on the command line 994 kwargs[attr] = val 995 996 # Properties should get flattened into the general kwargs 997 if getattr(parsed_args, 'properties', None): 998 for k, v in parsed_args.properties.items(): 999 kwargs[k] = str(v) 1000 1001 # Handle exclusive booleans with care 1002 # Avoid including attributes in kwargs if an option is not 1003 # present on the command line. These exclusive booleans are not 1004 # a single value for the pair of options because the default must be 1005 # to do nothing when no options are present as opposed to always 1006 # setting a default. 1007 if parsed_args.protected: 1008 kwargs['is_protected'] = True 1009 if parsed_args.unprotected: 1010 kwargs['is_protected'] = False 1011 if parsed_args.public: 1012 kwargs['visibility'] = 'public' 1013 if parsed_args.private: 1014 kwargs['visibility'] = 'private' 1015 if parsed_args.community: 1016 kwargs['visibility'] = 'community' 1017 if parsed_args.shared: 1018 kwargs['visibility'] = 'shared' 1019 project_id = None 1020 if parsed_args.project: 1021 project_id = common.find_project( 1022 identity_client, 1023 parsed_args.project, 1024 parsed_args.project_domain, 1025 ).id 1026 kwargs['owner_id'] = project_id 1027 1028 image = image_client.find_image(parsed_args.image, 1029 ignore_missing=False) 1030 1031 # image = utils.find_resource( 1032 # image_client.images, parsed_args.image) 1033 1034 activation_status = None 1035 if parsed_args.deactivate: 1036 image_client.deactivate_image(image.id) 1037 activation_status = "deactivated" 1038 if parsed_args.activate: 1039 image_client.reactivate_image(image.id) 1040 activation_status = "activated" 1041 1042 membership_group_args = ('accept', 'reject', 'pending') 1043 membership_status = [status for status in membership_group_args 1044 if getattr(parsed_args, status)] 1045 if membership_status: 1046 # If a specific project is not passed, assume we want to update 1047 # our own membership 1048 if not project_id: 1049 project_id = self.app.client_manager.auth_ref.project_id 1050 # The mutually exclusive group of the arg parser ensure we have at 1051 # most one item in the membership_status list. 1052 if membership_status[0] != 'pending': 1053 membership_status[0] += 'ed' # Glance expects the past form 1054 image_client.update_member( 1055 image=image.id, member=project_id, status=membership_status[0]) 1056 1057 if parsed_args.tags: 1058 # Tags should be extended, but duplicates removed 1059 kwargs['tags'] = list(set(image.tags).union(set(parsed_args.tags))) 1060 1061 try: 1062 image = image_client.update_image(image.id, **kwargs) 1063 except Exception: 1064 if activation_status is not None: 1065 LOG.info(_("Image %(id)s was %(status)s."), 1066 {'id': image.id, 'status': activation_status}) 1067 raise 1068 1069 1070class ShowImage(command.ShowOne): 1071 _description = _("Display image details") 1072 1073 def get_parser(self, prog_name): 1074 parser = super(ShowImage, self).get_parser(prog_name) 1075 parser.add_argument( 1076 "--human-readable", 1077 default=False, 1078 action='store_true', 1079 help=_("Print image size in a human-friendly format."), 1080 ) 1081 parser.add_argument( 1082 "image", 1083 metavar="<image>", 1084 help=_("Image to display (name or ID)"), 1085 ) 1086 return parser 1087 1088 def take_action(self, parsed_args): 1089 image_client = self.app.client_manager.image 1090 1091 image = image_client.find_image(parsed_args.image, 1092 ignore_missing=False) 1093 1094 info = _format_image(image, parsed_args.human_readable) 1095 return zip(*sorted(info.items())) 1096 1097 1098class UnsetImage(command.Command): 1099 _description = _("Unset image tags and properties") 1100 1101 def get_parser(self, prog_name): 1102 parser = super(UnsetImage, self).get_parser(prog_name) 1103 parser.add_argument( 1104 "image", 1105 metavar="<image>", 1106 help=_("Image to modify (name or ID)"), 1107 ) 1108 parser.add_argument( 1109 "--tag", 1110 dest="tags", 1111 metavar="<tag>", 1112 default=[], 1113 action='append', 1114 help=_("Unset a tag on this image " 1115 "(repeat option to unset multiple tags)"), 1116 ) 1117 parser.add_argument( 1118 "--property", 1119 dest="properties", 1120 metavar="<property-key>", 1121 default=[], 1122 action='append', 1123 help=_("Unset a property on this image " 1124 "(repeat option to unset multiple properties)"), 1125 ) 1126 return parser 1127 1128 def take_action(self, parsed_args): 1129 image_client = self.app.client_manager.image 1130 image = image_client.find_image(parsed_args.image, 1131 ignore_missing=False) 1132 1133 kwargs = {} 1134 tagret = 0 1135 propret = 0 1136 if parsed_args.tags: 1137 for k in parsed_args.tags: 1138 try: 1139 image_client.remove_tag(image.id, k) 1140 except Exception: 1141 LOG.error(_("tag unset failed, '%s' is a " 1142 "nonexistent tag "), k) 1143 tagret += 1 1144 1145 if parsed_args.properties: 1146 for k in parsed_args.properties: 1147 if k in image: 1148 kwargs[k] = None 1149 elif k in image.properties: 1150 # Since image is an "evil" object from SDK POV we need to 1151 # pass modified properties object, so that SDK can figure 1152 # out, what was changed inside 1153 # NOTE: ping gtema to improve that in SDK 1154 new_props = kwargs.get('properties', 1155 image.get('properties').copy()) 1156 new_props.pop(k, None) 1157 kwargs['properties'] = new_props 1158 else: 1159 LOG.error(_("property unset failed, '%s' is a " 1160 "nonexistent property "), k) 1161 propret += 1 1162 1163 # We must give to update a current image for the reference on what 1164 # has changed 1165 image_client.update_image( 1166 image, 1167 **kwargs) 1168 1169 tagtotal = len(parsed_args.tags) 1170 proptotal = len(parsed_args.properties) 1171 if (tagret > 0 and propret > 0): 1172 msg = (_("Failed to unset %(tagret)s of %(tagtotal)s tags," 1173 "Failed to unset %(propret)s of %(proptotal)s properties.") 1174 % {'tagret': tagret, 'tagtotal': tagtotal, 1175 'propret': propret, 'proptotal': proptotal}) 1176 raise exceptions.CommandError(msg) 1177 elif tagret > 0: 1178 msg = (_("Failed to unset %(tagret)s of %(tagtotal)s tags.") 1179 % {'tagret': tagret, 'tagtotal': tagtotal}) 1180 raise exceptions.CommandError(msg) 1181 elif propret > 0: 1182 msg = (_("Failed to unset %(propret)s of %(proptotal)s" 1183 " properties.") 1184 % {'propret': propret, 'proptotal': proptotal}) 1185 raise exceptions.CommandError(msg) 1186