Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / commands / image.py @ 8c54338a

History | View | Annotate | Download (23.7 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('global', 'token')
79

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

    
90
        self.client = ImageClient(base_url=base_url, token=token)
91
        self._set_log_params()
92
        self._update_max_threads()
93

    
94
    def main(self):
95
        self._run()
96

    
97

    
98
# Plankton Image Commands
99

    
100

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

106
    :param return_str: (boolean) if true, return a json dump
107

108
    :returns: (dict) if return_str is not True, else return str
109

110
    :raises TypeError, AttributeError: Invalid json format
111

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

    
130

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

135
    :returns: (dict) json_formated
136

137
    :raises TypeError, AttributeError: Invalid json format
138

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

    
149

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

154
    :returns: (<uuid>, <container>, <img-file-path>)
155

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

    
169

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

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

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

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

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

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

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

    
253
    def main(self):
254
        super(self.__class__, self)._run()
255
        self._run()
256

    
257

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

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

    
273
    def main(self, image_id):
274
        super(self.__class__, self)._run()
275
        self._run(image_id=image_id)
276

    
277

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

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

    
305
    )
306

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

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

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

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

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

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

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

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

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

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

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

    
443

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

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

    
454
    def main(self, image_id):
455
        super(self.__class__, self)._run()
456
        self._run(image_id=image_id)
457

    
458

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

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

    
468
    def main(self, member):
469
        super(self.__class__, self)._run()
470
        self._run(member)
471

    
472

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

    
477

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

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

    
488
    def main(self, image_id):
489
        super(self.__class__, self)._run()
490
        self._run(image_id=image_id)
491

    
492

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

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

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

    
507

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

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

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

    
522

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

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

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

    
537

    
538
# Compute Image Commands
539

    
540

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

    
545

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

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

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

    
570
    def main(self):
571
        super(self.__class__, self)._run()
572
        self._run()
573

    
574

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

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

    
586
    def main(self, image_id):
587
        super(self.__class__, self)._run()
588
        self._run(image_id=image_id)
589

    
590

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

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

    
601
    def main(self, image_id):
602
        super(self.__class__, self)._run()
603
        self._run(image_id=image_id)
604

    
605

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

    
610

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

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

    
621
    def main(self, image_id):
622
        super(self.__class__, self)._run()
623
        self._run(image_id=image_id)
624

    
625

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

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

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

    
641

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

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

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

    
658

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

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

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

    
681

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

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

    
693
    def main(self, image_id, key):
694
        super(self.__class__, self)._run()
695
        self._run(image_id=image_id, key=key)