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