Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (24 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_remote(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')\
89
                or self._custom_type('plankton') or 'image',
90
                self._custom_version('image')\
91
                or self._custom_version('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
                self.config.get('pithos', 'type'),
330
                self.config.get('pithos', 'version'))
331
            purl = pithos_endpoints['publicURL']
332
        else:
333
            purl = self.config.get('file', 'url')\
334
                or self.config.get('pithos', 'url')
335
            if not purl:
336
                raise CLIBaseUrlError(service='pithos')
337
        return PithosClient(purl, ptoken, self._get_user_id(), container)
338

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

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

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

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

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

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

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

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

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

    
447

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

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

    
458
    def main(self, image_id):
459
        super(self.__class__, self)._run()
460
        self._run(image_id=image_id)
461

    
462

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

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

    
472
    def main(self, member):
473
        super(self.__class__, self)._run()
474
        self._run(member)
475

    
476

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

    
481

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

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

    
492
    def main(self, image_id):
493
        super(self.__class__, self)._run()
494
        self._run(image_id=image_id)
495

    
496

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

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

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

    
511

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

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

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

    
526

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

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

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

    
541

    
542
# Compute Image Commands
543

    
544

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

    
549

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

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

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

    
574
    def main(self):
575
        super(self.__class__, self)._run()
576
        self._run()
577

    
578

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

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

    
590
    def main(self, image_id):
591
        super(self.__class__, self)._run()
592
        self._run(image_id=image_id)
593

    
594

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

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

    
605
    def main(self, image_id):
606
        super(self.__class__, self)._run()
607
        self._run(image_id=image_id)
608

    
609

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

    
614

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

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

    
625
    def main(self, image_id):
626
        super(self.__class__, self)._run()
627
        self._run(image_id=image_id)
628

    
629

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

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

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

    
645

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

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

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

    
662

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

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

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

    
685

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

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

    
697
    def main(self, image_id, key):
698
        super(self.__class__, self)._run()
699
        self._run(image_id=image_id, key=key)