Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / commands / image.py @ f5c28bfa

History | View | Annotate | Download (23.8 kB)

1
# Copyright 2012 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.command
33

    
34
from json import load, dumps
35
from os.path import abspath
36
from logging import getLogger
37

    
38
from kamaki.cli import command
39
from kamaki.cli.command_tree import CommandTree
40
from kamaki.cli.utils import print_dict, print_json
41
from kamaki.clients.image import ImageClient
42
from kamaki.clients.pithos import PithosClient
43
from kamaki.clients.astakos import AstakosClient
44
from kamaki.clients import ClientError
45
from kamaki.cli.argument import FlagArgument, ValueArgument, KeyValueArgument
46
from kamaki.cli.argument import IntArgument
47
from kamaki.cli.commands.cyclades import _init_cyclades
48
from kamaki.cli.errors import raiseCLIError, CLIBaseUrlError
49
from kamaki.cli.commands import _command_init, errors
50
from kamaki.cli.commands import _optional_output_cmd, _optional_json
51

    
52

    
53
image_cmds = CommandTree(
54
    'image',
55
    'Cyclades/Plankton API image commands\n'
56
    'image compute:\tCyclades/Compute API image commands')
57
_commands = [image_cmds]
58

    
59

    
60
howto_image_file = [
61
    'Kamaki commands to:',
62
    ' get current user uuid: /user authenticate',
63
    ' check available containers: /file list',
64
    ' create a new container: /file create <container>',
65
    ' check container contents: /file list <container>',
66
    ' upload files: /file upload <image file> <container>']
67

    
68
about_image_id = ['To see a list of available image ids: /image list']
69

    
70

    
71
log = getLogger(__name__)
72

    
73

    
74
class _init_image(_command_init):
75
    @errors.generic.all
76
    def _run(self):
77
        token = self.config.get('image', 'token')\
78
            or self.config.get('plankton', 'token')\
79
            or self.config.get('global', 'token')
80

    
81
        if getattr(self, 'auth_base', False):
82
            plankton_endpoints = self.auth_base.get_service_endpoints(
83
                self.config.get('image', 'type'),
84
                self.config.get('image', 'version'))
85
            base_url = plankton_endpoints['publicURL']
86
        else:
87
            base_url = self.config.get('image', 'url')\
88
                or self.config.get('plankton', 'url')
89
        if not base_url:
90
            raise CLIBaseUrlError(service='plankton')
91

    
92
        self.client = ImageClient(base_url=base_url, token=token)
93
        self._set_log_params()
94
        self._update_max_threads()
95

    
96
    def main(self):
97
        self._run()
98

    
99

    
100
# Plankton Image Commands
101

    
102

    
103
def _validate_image_meta(json_dict, return_str=False):
104
    """
105
    :param json_dict" (dict) json-formated, of the form
106
        {"key1": "val1", "key2": "val2", ...}
107

108
    :param return_str: (boolean) if true, return a json dump
109

110
    :returns: (dict) if return_str is not True, else return str
111

112
    :raises TypeError, AttributeError: Invalid json format
113

114
    :raises AssertionError: Valid json but invalid image properties dict
115
    """
116
    json_str = dumps(json_dict, indent=2)
117
    for k, v in json_dict.items():
118
        if k.lower() == 'properties':
119
            for pk, pv in v.items():
120
                prop_ok = not (isinstance(pv, dict) or isinstance(pv, list))
121
                assert prop_ok, 'Invalid property value for key %s' % pk
122
                key_ok = not (' ' in k or '-' in k)
123
                assert key_ok, 'Invalid property key %s' % k
124
            continue
125
        meta_ok = not (isinstance(v, dict) or isinstance(v, list))
126
        assert meta_ok, 'Invalid value for meta key %s' % k
127
        meta_ok = ' ' not in k
128
        assert meta_ok, 'Invalid meta key [%s]' % k
129
        json_dict[k] = '%s' % v
130
    return json_str if return_str else json_dict
131

    
132

    
133
def _load_image_meta(filepath):
134
    """
135
    :param filepath: (str) the (relative) path of the metafile
136

137
    :returns: (dict) json_formated
138

139
    :raises TypeError, AttributeError: Invalid json format
140

141
    :raises AssertionError: Valid json but invalid image properties dict
142
    """
143
    with open(abspath(filepath)) as f:
144
        meta_dict = load(f)
145
        try:
146
            return _validate_image_meta(meta_dict)
147
        except AssertionError:
148
            log.debug('Failed to load properties from file %s' % filepath)
149
            raise
150

    
151

    
152
def _validate_image_location(location):
153
    """
154
    :param location: (str) pithos://<uuid>/<container>/<img-file-path>
155

156
    :returns: (<uuid>, <container>, <img-file-path>)
157

158
    :raises AssertionError: if location is invalid
159
    """
160
    prefix = 'pithos://'
161
    msg = 'Invalid prefix for location %s , try: %s' % (location, prefix)
162
    assert location.startswith(prefix), msg
163
    service, sep, rest = location.partition('://')
164
    assert sep and rest, 'Location %s is missing uuid' % location
165
    uuid, sep, rest = rest.partition('/')
166
    assert sep and rest, 'Location %s is missing container' % location
167
    container, sep, img_path = rest.partition('/')
168
    assert sep and img_path, 'Location %s is missing image path' % location
169
    return uuid, container, img_path
170

    
171

    
172
@command(image_cmds)
173
class image_list(_init_image, _optional_json):
174
    """List images accessible by user"""
175

    
176
    arguments = dict(
177
        detail=FlagArgument('show detailed output', ('-l', '--details')),
178
        container_format=ValueArgument(
179
            'filter by container format',
180
            '--container-format'),
181
        disk_format=ValueArgument('filter by disk format', '--disk-format'),
182
        name=ValueArgument('filter by name', '--name'),
183
        name_pref=ValueArgument(
184
            'filter by name prefix (case insensitive)',
185
            '--name-prefix'),
186
        name_suff=ValueArgument(
187
            'filter by name suffix (case insensitive)',
188
            '--name-suffix'),
189
        name_like=ValueArgument(
190
            'print only if name contains this (case insensitive)',
191
            '--name-like'),
192
        size_min=IntArgument('filter by minimum size', '--size-min'),
193
        size_max=IntArgument('filter by maximum size', '--size-max'),
194
        status=ValueArgument('filter by status', '--status'),
195
        owner=ValueArgument('filter by owner', '--owner'),
196
        order=ValueArgument(
197
            'order by FIELD ( - to reverse order)',
198
            '--order',
199
            default=''),
200
        limit=IntArgument('limit number of listed images', ('-n', '--number')),
201
        more=FlagArgument(
202
            'output results in pages (-n to set items per page, default 10)',
203
            '--more'),
204
        enum=FlagArgument('Enumerate results', '--enumerate')
205
    )
206

    
207
    def _filtered_by_owner(self, detail, *list_params):
208
        images = []
209
        MINKEYS = set([
210
            'id', 'size', 'status', 'disk_format', 'container_format', 'name'])
211
        for img in self.client.list_public(True, *list_params):
212
            if img['owner'] == self['owner']:
213
                if not detail:
214
                    for key in set(img.keys()).difference(MINKEYS):
215
                        img.pop(key)
216
                images.append(img)
217
        return images
218

    
219
    def _filtered_by_name(self, images):
220
        np, ns, nl = self['name_pref'], self['name_suff'], self['name_like']
221
        return [img for img in images if (
222
            (not np) or img['name'].lower().startswith(np.lower())) and (
223
            (not ns) or img['name'].lower().endswith(ns.lower())) and (
224
            (not nl) or nl.lower() in img['name'].lower())]
225

    
226
    @errors.generic.all
227
    @errors.cyclades.connection
228
    def _run(self):
229
        super(self.__class__, self)._run()
230
        filters = {}
231
        for arg in set([
232
                'container_format',
233
                'disk_format',
234
                'name',
235
                'size_min',
236
                'size_max',
237
                'status']).intersection(self.arguments):
238
            filters[arg] = self[arg]
239

    
240
        order = self['order']
241
        detail = self['detail']
242
        if self['owner']:
243
            images = self._filtered_by_owner(detail, filters, order)
244
        else:
245
            images = self.client.list_public(detail, filters, order)
246

    
247
        images = self._filtered_by_name(images)
248
        kwargs = dict(with_enumeration=self['enum'])
249
        if self['more']:
250
            kwargs['page_size'] = self['limit'] or 10
251
        elif self['limit']:
252
            images = images[:self['limit']]
253
        self._print(images, **kwargs)
254

    
255
    def main(self):
256
        super(self.__class__, self)._run()
257
        self._run()
258

    
259

    
260
@command(image_cmds)
261
class image_meta(_init_image, _optional_json):
262
    """Get image metadata
263
    Image metadata include:
264
    - image file information (location, size, etc.)
265
    - image information (id, name, etc.)
266
    - image os properties (os, fs, etc.)
267
    """
268

    
269
    @errors.generic.all
270
    @errors.plankton.connection
271
    @errors.plankton.id
272
    def _run(self, image_id):
273
        self._print([self.client.get_meta(image_id)])
274

    
275
    def main(self, image_id):
276
        super(self.__class__, self)._run()
277
        self._run(image_id=image_id)
278

    
279

    
280
@command(image_cmds)
281
class image_register(_init_image, _optional_json):
282
    """(Re)Register an image"""
283

    
284
    arguments = dict(
285
        checksum=ValueArgument('set image checksum', '--checksum'),
286
        container_format=ValueArgument(
287
            'set container format',
288
            '--container-format'),
289
        disk_format=ValueArgument('set disk format', '--disk-format'),
290
        owner=ValueArgument('set image owner (admin only)', '--owner'),
291
        properties=KeyValueArgument(
292
            'add property in key=value form (can be repeated)',
293
            ('-p', '--property')),
294
        is_public=FlagArgument('mark image as public', '--public'),
295
        size=IntArgument('set image size', '--size'),
296
        metafile=ValueArgument(
297
            'Load metadata from a json-formated file <img-file>.meta :'
298
            '{"key1": "val1", "key2": "val2", ..., "properties: {...}"}',
299
            ('--metafile')),
300
        metafile_force=FlagArgument(
301
            'Store remote metadata object, even if it already exists',
302
            ('-f', '--force')),
303
        no_metafile_upload=FlagArgument(
304
            'Do not store metadata in remote meta file',
305
            ('--no-metafile-upload')),
306

    
307
    )
308

    
309
    def _get_user_id(self):
310
        atoken = self.client.token
311
        if getattr(self, 'auth_base', False):
312
            return self.auth_base.term('id', atoken)
313
        else:
314
            astakos_url = self.config.get('user', 'url')\
315
                or self.config.get('astakos', 'url')
316
            if not astakos_url:
317
                raise CLIBaseUrlError(service='astakos')
318
            user = AstakosClient(astakos_url, atoken)
319
            return user.term('id')
320

    
321
    def _get_pithos_client(self, container):
322
        if self['no_metafile_upload']:
323
            return None
324
        ptoken = self.client.token
325
        if getattr(self, 'auth_base', False):
326
            pithos_endpoints = self.auth_base.get_service_endpoints(
327
                self.config.get('pithos', 'type'),
328
                self.config.get('pithos', 'version'))
329
            purl = pithos_endpoints['publicURL']
330
        else:
331
            purl = self.config.get('file', 'url')\
332
                or self.config.get('pithos', 'url')
333
            if not purl:
334
                raise CLIBaseUrlError(service='pithos')
335
        return PithosClient(purl, ptoken, self._get_user_id(), container)
336

    
337
    def _store_remote_metafile(self, pclient, remote_path, metadata):
338
        return pclient.upload_from_string(
339
            remote_path, _validate_image_meta(metadata, return_str=True))
340

    
341
    def _load_params_from_file(self, location):
342
        params, properties = dict(), dict()
343
        pfile = self['metafile']
344
        if pfile:
345
            try:
346
                for k, v in _load_image_meta(pfile).items():
347
                    key = k.lower().replace('-', '_')
348
                    if k == 'properties':
349
                        for pk, pv in v.items():
350
                            properties[pk.upper().replace('-', '_')] = pv
351
                    elif key == 'name':
352
                            continue
353
                    elif key == 'location':
354
                        if location:
355
                            continue
356
                        location = v
357
                    else:
358
                        params[key] = v
359
            except Exception as e:
360
                raiseCLIError(e, 'Invalid json metadata config file')
361
        return params, properties, location
362

    
363
    def _load_params_from_args(self, params, properties):
364
        for key in set([
365
                'checksum',
366
                'container_format',
367
                'disk_format',
368
                'owner',
369
                'size',
370
                'is_public']).intersection(self.arguments):
371
            params[key] = self[key]
372
        for k, v in self['properties'].items():
373
            properties[k.upper().replace('-', '_')] = v
374

    
375
    def _validate_location(self, location):
376
        if not location:
377
            raiseCLIError(
378
                'No image file location provided',
379
                importance=2, details=[
380
                    'An image location is needed. Image location format:',
381
                    '  pithos://<uuid>/<container>/<path>',
382
                    ' an image file at the above location must exist.'
383
                    ] + howto_image_file)
384
        try:
385
            return _validate_image_location(location)
386
        except AssertionError as ae:
387
            raiseCLIError(
388
                ae, 'Invalid image location format',
389
                importance=1, details=[
390
                    'Valid image location format:',
391
                    '  pithos://<uuid>/<container>/<img-file-path>'
392
                    ] + howto_image_file)
393

    
394
    @errors.generic.all
395
    @errors.plankton.connection
396
    def _run(self, name, location):
397
        (params, properties, location) = self._load_params_from_file(location)
398
        uuid, container, img_path = self._validate_location(location)
399
        self._load_params_from_args(params, properties)
400
        pclient = self._get_pithos_client(container)
401

    
402
        #check if metafile exists
403
        meta_path = '%s.meta' % img_path
404
        if pclient and not self['metafile_force']:
405
            try:
406
                pclient.get_object_info(meta_path)
407
                raiseCLIError('Metadata file %s:%s already exists' % (
408
                    container, meta_path))
409
            except ClientError as ce:
410
                if ce.status != 404:
411
                    raise
412

    
413
        #register the image
414
        try:
415
            r = self.client.register(name, location, params, properties)
416
        except ClientError as ce:
417
            if ce.status in (400, ):
418
                raiseCLIError(
419
                    ce, 'Nonexistent image file location %s' % location,
420
                    details=[
421
                        'Make sure the image file exists'] + howto_image_file)
422
            raise
423
        self._print(r, print_dict)
424

    
425
        #upload the metadata file
426
        if pclient:
427
            try:
428
                meta_headers = pclient.upload_from_string(
429
                    meta_path, dumps(r, indent=2))
430
            except TypeError:
431
                print('Failed to dump metafile %s:%s' % (container, meta_path))
432
                return
433
            if self['json_output']:
434
                print_json(dict(
435
                    metafile_location='%s:%s' % (container, meta_path),
436
                    headers=meta_headers))
437
            else:
438
                print('Metadata file uploaded as %s:%s (version %s)' % (
439
                    container, meta_path, meta_headers['x-object-version']))
440

    
441
    def main(self, name, location=None):
442
        super(self.__class__, self)._run()
443
        self._run(name, location)
444

    
445

    
446
@command(image_cmds)
447
class image_unregister(_init_image, _optional_output_cmd):
448
    """Unregister an image (does not delete the image file)"""
449

    
450
    @errors.generic.all
451
    @errors.plankton.connection
452
    @errors.plankton.id
453
    def _run(self, image_id):
454
        self._optional_output(self.client.unregister(image_id))
455

    
456
    def main(self, image_id):
457
        super(self.__class__, self)._run()
458
        self._run(image_id=image_id)
459

    
460

    
461
@command(image_cmds)
462
class image_shared(_init_image, _optional_json):
463
    """List images shared by a member"""
464

    
465
    @errors.generic.all
466
    @errors.plankton.connection
467
    def _run(self, member):
468
        self._print(self.client.list_shared(member), title=('image_id',))
469

    
470
    def main(self, member):
471
        super(self.__class__, self)._run()
472
        self._run(member)
473

    
474

    
475
@command(image_cmds)
476
class image_members(_init_image):
477
    """Manage members. Members of an image are users who can modify it"""
478

    
479

    
480
@command(image_cmds)
481
class image_members_list(_init_image, _optional_json):
482
    """List members of an image"""
483

    
484
    @errors.generic.all
485
    @errors.plankton.connection
486
    @errors.plankton.id
487
    def _run(self, image_id):
488
        self._print(self.client.list_members(image_id), title=('member_id',))
489

    
490
    def main(self, image_id):
491
        super(self.__class__, self)._run()
492
        self._run(image_id=image_id)
493

    
494

    
495
@command(image_cmds)
496
class image_members_add(_init_image, _optional_output_cmd):
497
    """Add a member to an image"""
498

    
499
    @errors.generic.all
500
    @errors.plankton.connection
501
    @errors.plankton.id
502
    def _run(self, image_id=None, member=None):
503
            self._optional_output(self.client.add_member(image_id, member))
504

    
505
    def main(self, image_id, member):
506
        super(self.__class__, self)._run()
507
        self._run(image_id=image_id, member=member)
508

    
509

    
510
@command(image_cmds)
511
class image_members_delete(_init_image, _optional_output_cmd):
512
    """Remove a member from an image"""
513

    
514
    @errors.generic.all
515
    @errors.plankton.connection
516
    @errors.plankton.id
517
    def _run(self, image_id=None, member=None):
518
            self._optional_output(self.client.remove_member(image_id, member))
519

    
520
    def main(self, image_id, member):
521
        super(self.__class__, self)._run()
522
        self._run(image_id=image_id, member=member)
523

    
524

    
525
@command(image_cmds)
526
class image_members_set(_init_image, _optional_output_cmd):
527
    """Set the members of an image"""
528

    
529
    @errors.generic.all
530
    @errors.plankton.connection
531
    @errors.plankton.id
532
    def _run(self, image_id, members):
533
            self._optional_output(self.client.set_members(image_id, members))
534

    
535
    def main(self, image_id, *members):
536
        super(self.__class__, self)._run()
537
        self._run(image_id=image_id, members=members)
538

    
539

    
540
# Compute Image Commands
541

    
542

    
543
@command(image_cmds)
544
class image_compute(_init_cyclades):
545
    """Cyclades/Compute API image commands"""
546

    
547

    
548
@command(image_cmds)
549
class image_compute_list(_init_cyclades, _optional_json):
550
    """List images"""
551

    
552
    arguments = dict(
553
        detail=FlagArgument('show detailed output', ('-l', '--details')),
554
        limit=IntArgument('limit number listed images', ('-n', '--number')),
555
        more=FlagArgument(
556
            'output results in pages (-n to set items per page, default 10)',
557
            '--more'),
558
        enum=FlagArgument('Enumerate results', '--enumerate')
559
    )
560

    
561
    @errors.generic.all
562
    @errors.cyclades.connection
563
    def _run(self):
564
        images = self.client.list_images(self['detail'])
565
        kwargs = dict(with_enumeration=self['enum'])
566
        if self['more']:
567
            kwargs['page_size'] = self['limit'] or 10
568
        elif self['limit']:
569
            images = images[:self['limit']]
570
        self._print(images, **kwargs)
571

    
572
    def main(self):
573
        super(self.__class__, self)._run()
574
        self._run()
575

    
576

    
577
@command(image_cmds)
578
class image_compute_info(_init_cyclades, _optional_json):
579
    """Get detailed information on an image"""
580

    
581
    @errors.generic.all
582
    @errors.cyclades.connection
583
    @errors.plankton.id
584
    def _run(self, image_id):
585
        image = self.client.get_image_details(image_id)
586
        self._print(image, print_dict)
587

    
588
    def main(self, image_id):
589
        super(self.__class__, self)._run()
590
        self._run(image_id=image_id)
591

    
592

    
593
@command(image_cmds)
594
class image_compute_delete(_init_cyclades, _optional_output_cmd):
595
    """Delete an image (WARNING: image file is also removed)"""
596

    
597
    @errors.generic.all
598
    @errors.cyclades.connection
599
    @errors.plankton.id
600
    def _run(self, image_id):
601
        self._optional_output(self.client.delete_image(image_id))
602

    
603
    def main(self, image_id):
604
        super(self.__class__, self)._run()
605
        self._run(image_id=image_id)
606

    
607

    
608
@command(image_cmds)
609
class image_compute_properties(_init_cyclades):
610
    """Manage properties related to OS installation in an image"""
611

    
612

    
613
@command(image_cmds)
614
class image_compute_properties_list(_init_cyclades, _optional_json):
615
    """List all image properties"""
616

    
617
    @errors.generic.all
618
    @errors.cyclades.connection
619
    @errors.plankton.id
620
    def _run(self, image_id):
621
        self._print(self.client.get_image_metadata(image_id), print_dict)
622

    
623
    def main(self, image_id):
624
        super(self.__class__, self)._run()
625
        self._run(image_id=image_id)
626

    
627

    
628
@command(image_cmds)
629
class image_compute_properties_get(_init_cyclades, _optional_json):
630
    """Get an image property"""
631

    
632
    @errors.generic.all
633
    @errors.cyclades.connection
634
    @errors.plankton.id
635
    @errors.plankton.metadata
636
    def _run(self, image_id, key):
637
        self._print(self.client.get_image_metadata(image_id, key), print_dict)
638

    
639
    def main(self, image_id, key):
640
        super(self.__class__, self)._run()
641
        self._run(image_id=image_id, key=key)
642

    
643

    
644
@command(image_cmds)
645
class image_compute_properties_add(_init_cyclades, _optional_json):
646
    """Add a property to an image"""
647

    
648
    @errors.generic.all
649
    @errors.cyclades.connection
650
    @errors.plankton.id
651
    @errors.plankton.metadata
652
    def _run(self, image_id, key, val):
653
        self._print(
654
            self.client.create_image_metadata(image_id, key, val), print_dict)
655

    
656
    def main(self, image_id, key, val):
657
        super(self.__class__, self)._run()
658
        self._run(image_id=image_id, key=key, val=val)
659

    
660

    
661
@command(image_cmds)
662
class image_compute_properties_set(_init_cyclades, _optional_json):
663
    """Add / update a set of properties for an image
664
    proeprties must be given in the form key=value, e.v.
665
    /image compute properties set <image-id> key1=val1 key2=val2
666
    """
667

    
668
    @errors.generic.all
669
    @errors.cyclades.connection
670
    @errors.plankton.id
671
    def _run(self, image_id, keyvals):
672
        meta = dict()
673
        for keyval in keyvals:
674
            key, val = keyval.split('=')
675
            meta[key] = val
676
        self._print(
677
            self.client.update_image_metadata(image_id, **meta), print_dict)
678

    
679
    def main(self, image_id, *key_equals_value):
680
        super(self.__class__, self)._run()
681
        self._run(image_id=image_id, keyvals=key_equals_value)
682

    
683

    
684
@command(image_cmds)
685
class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
686
    """Delete a property from an image"""
687

    
688
    @errors.generic.all
689
    @errors.cyclades.connection
690
    @errors.plankton.id
691
    @errors.plankton.metadata
692
    def _run(self, image_id, key):
693
        self._optional_output(self.client.delete_image_metadata(image_id, key))
694

    
695
    def main(self, image_id, key):
696
        super(self.__class__, self)._run()
697
        self._run(image_id=image_id, key=key)