Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (23.9 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, addLogSettings
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 id: /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
    @addLogSettings
77
    def _run(self):
78
        if getattr(self, 'cloud', None):
79
            img_url = self._custom_url('image') or self._custom_url('plankton')
80
            if img_url:
81
                token = self._custom_token('image')\
82
                    or self._custom_token('plankton')\
83
                    or self.config.get_cloud(self.cloud, 'token')
84
                self.client = ImageClient(base_url=img_url, token=token)
85
                return
86
        if getattr(self, 'auth_base', False):
87
            plankton_endpoints = self.auth_base.get_service_endpoints(
88
                self._custom_type('image') or self._custom_type(
89
                    'plankton') or 'image',
90
                self._custom_version('image') or self._custom_version(
91
                    'plankton') or '')
92
            base_url = plankton_endpoints['publicURL']
93
            token = self.auth_base.token
94
        else:
95
            raise CLIBaseUrlError(service='plankton')
96
        self.client = ImageClient(base_url=base_url, token=token)
97

    
98
    def main(self):
99
        self._run()
100

    
101

    
102
# Plankton Image Commands
103

    
104

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

110
    :param return_str: (boolean) if true, return a json dump
111

112
    :returns: (dict) if return_str is not True, else return str
113

114
    :raises TypeError, AttributeError: Invalid json format
115

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

    
134

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

139
    :returns: (dict) json_formated
140

141
    :raises TypeError, AttributeError: Invalid json format
142

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

    
153

    
154
def _validate_image_location(location):
155
    """
156
    :param location: (str) pithos://<user-id>/<container>/<img-file-path>
157

158
    :returns: (<user-id>, <container>, <img-file-path>)
159

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

    
173

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

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

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

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

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

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

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

    
257
    def main(self):
258
        super(self.__class__, self)._run()
259
        self._run()
260

    
261

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

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

    
277
    def main(self, image_id):
278
        super(self.__class__, self)._run()
279
        self._run(image_id=image_id)
280

    
281

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

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

    
309
    )
310

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

    
323
    def _get_pithos_client(self, container):
324
        if self['no_metafile_upload']:
325
            return None
326
        ptoken = self.client.token
327
        if getattr(self, 'auth_base', False):
328
            pithos_endpoints = self.auth_base.get_service_endpoints(
329
                'object-store')
330
            purl = pithos_endpoints['publicURL']
331
        else:
332
            purl = self.config.get_cloud('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://<user-id>/<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://<user-id>/<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)