Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (25 kB)

1
# Copyright 2012-2013 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>/<image-path>
157

158
    :returns: (<user-id>, <container>, <image-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
        container=ValueArgument(
309
            'Pithos+ container containing the image file',
310
            ('-C', '--container')),
311
        uuid=ValueArgument('Custom user uuid', '--uuid')
312
    )
313

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

    
326
    def _get_pithos_client(self, container):
327
        if self['no_metafile_upload']:
328
            return None
329
        ptoken = self.client.token
330
        if getattr(self, 'auth_base', False):
331
            pithos_endpoints = self.auth_base.get_service_endpoints(
332
                'object-store')
333
            purl = pithos_endpoints['publicURL']
334
        else:
335
            purl = self.config.get_cloud('pithos', 'url')
336
        if not purl:
337
            raise CLIBaseUrlError(service='pithos')
338
        return PithosClient(purl, ptoken, self._get_user_id(), container)
339

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

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

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

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

    
397
    def _mine_location(self, container_path):
398
        uuid = self['uuid'] or self._get_user_id()
399
        if self['container']:
400
            return uuid, self['container'], container_path
401
        container, sep, path = container_path.partition(':')
402
        if not (bool(container) and bool(path)):
403
            raiseCLIError(
404
                'Incorrect container-path format', importance=1, details=[
405
                'Use : to seperate container form path',
406
                '  <container>:<image-path>',
407
                'OR',
408
                'Use -C to specifiy a container',
409
                '  -C <container> <image-path>'] + howto_image_file)
410

    
411
        return uuid, container, path
412

    
413
    @errors.generic.all
414
    @errors.plankton.connection
415
    def _run(self, name, uuid, container, img_path):
416
        location = 'pithos://%s/%s/%s' % (uuid, container, img_path)
417
        (params, properties, new_loc) = self._load_params_from_file(location)
418
        if location != new_loc:
419
            uuid, container, img_path = self._validate_location(new_loc)
420
        self._load_params_from_args(params, properties)
421
        pclient = self._get_pithos_client(container)
422

    
423
        #check if metafile exists
424
        meta_path = '%s.meta' % img_path
425
        if pclient and not self['metafile_force']:
426
            try:
427
                pclient.get_object_info(meta_path)
428
                raiseCLIError(
429
                    'Metadata file %s:%s already exists, abort' % (
430
                        container, meta_path),
431
                    details=['Registration ABORTED', 'Try -f to overwrite'])
432
            except ClientError as ce:
433
                if ce.status != 404:
434
                    raise
435

    
436
        #register the image
437
        try:
438
            r = self.client.register(name, location, params, properties)
439
        except ClientError as ce:
440
            if ce.status in (400, ):
441
                raiseCLIError(
442
                    ce, 'Nonexistent image file location %s' % location,
443
                    details=[
444
                        'Make sure the image file exists'] + howto_image_file)
445
            raise
446
        self._print(r, print_dict)
447

    
448
        #upload the metadata file
449
        if pclient:
450
            try:
451
                meta_headers = pclient.upload_from_string(
452
                    meta_path, dumps(r, indent=2))
453
            except TypeError:
454
                print('Failed to dump metafile %s:%s' % (container, meta_path))
455
                return
456
            if self['json_output']:
457
                print_json(dict(
458
                    metafile_location='%s:%s' % (container, meta_path),
459
                    headers=meta_headers))
460
            else:
461
                print('Metadata file uploaded as %s:%s (version %s)' % (
462
                    container, meta_path, meta_headers['x-object-version']))
463

    
464
    def main(self, name, container___image_path):
465
        super(self.__class__, self)._run()
466
        self._run(name, *self._mine_location(container___image_path))
467

    
468

    
469
@command(image_cmds)
470
class image_unregister(_init_image, _optional_output_cmd):
471
    """Unregister an image (does not delete the image file)"""
472

    
473
    @errors.generic.all
474
    @errors.plankton.connection
475
    @errors.plankton.id
476
    def _run(self, image_id):
477
        self._optional_output(self.client.unregister(image_id))
478

    
479
    def main(self, image_id):
480
        super(self.__class__, self)._run()
481
        self._run(image_id=image_id)
482

    
483

    
484
@command(image_cmds)
485
class image_shared(_init_image, _optional_json):
486
    """List images shared by a member"""
487

    
488
    @errors.generic.all
489
    @errors.plankton.connection
490
    def _run(self, member):
491
        self._print(self.client.list_shared(member), title=('image_id',))
492

    
493
    def main(self, member):
494
        super(self.__class__, self)._run()
495
        self._run(member)
496

    
497

    
498
@command(image_cmds)
499
class image_members(_init_image):
500
    """Manage members. Members of an image are users who can modify it"""
501

    
502

    
503
@command(image_cmds)
504
class image_members_list(_init_image, _optional_json):
505
    """List members of an image"""
506

    
507
    @errors.generic.all
508
    @errors.plankton.connection
509
    @errors.plankton.id
510
    def _run(self, image_id):
511
        self._print(self.client.list_members(image_id), title=('member_id',))
512

    
513
    def main(self, image_id):
514
        super(self.__class__, self)._run()
515
        self._run(image_id=image_id)
516

    
517

    
518
@command(image_cmds)
519
class image_members_add(_init_image, _optional_output_cmd):
520
    """Add a member to an image"""
521

    
522
    @errors.generic.all
523
    @errors.plankton.connection
524
    @errors.plankton.id
525
    def _run(self, image_id=None, member=None):
526
            self._optional_output(self.client.add_member(image_id, member))
527

    
528
    def main(self, image_id, member):
529
        super(self.__class__, self)._run()
530
        self._run(image_id=image_id, member=member)
531

    
532

    
533
@command(image_cmds)
534
class image_members_delete(_init_image, _optional_output_cmd):
535
    """Remove a member from an image"""
536

    
537
    @errors.generic.all
538
    @errors.plankton.connection
539
    @errors.plankton.id
540
    def _run(self, image_id=None, member=None):
541
            self._optional_output(self.client.remove_member(image_id, member))
542

    
543
    def main(self, image_id, member):
544
        super(self.__class__, self)._run()
545
        self._run(image_id=image_id, member=member)
546

    
547

    
548
@command(image_cmds)
549
class image_members_set(_init_image, _optional_output_cmd):
550
    """Set the members of an image"""
551

    
552
    @errors.generic.all
553
    @errors.plankton.connection
554
    @errors.plankton.id
555
    def _run(self, image_id, members):
556
            self._optional_output(self.client.set_members(image_id, members))
557

    
558
    def main(self, image_id, *members):
559
        super(self.__class__, self)._run()
560
        self._run(image_id=image_id, members=members)
561

    
562

    
563
# Compute Image Commands
564

    
565

    
566
@command(image_cmds)
567
class image_compute(_init_cyclades):
568
    """Cyclades/Compute API image commands"""
569

    
570

    
571
@command(image_cmds)
572
class image_compute_list(_init_cyclades, _optional_json):
573
    """List images"""
574

    
575
    arguments = dict(
576
        detail=FlagArgument('show detailed output', ('-l', '--details')),
577
        limit=IntArgument('limit number listed images', ('-n', '--number')),
578
        more=FlagArgument(
579
            'output results in pages (-n to set items per page, default 10)',
580
            '--more'),
581
        enum=FlagArgument('Enumerate results', '--enumerate')
582
    )
583

    
584
    @errors.generic.all
585
    @errors.cyclades.connection
586
    def _run(self):
587
        images = self.client.list_images(self['detail'])
588
        kwargs = dict(with_enumeration=self['enum'])
589
        if self['more']:
590
            kwargs['page_size'] = self['limit'] or 10
591
        elif self['limit']:
592
            images = images[:self['limit']]
593
        self._print(images, **kwargs)
594

    
595
    def main(self):
596
        super(self.__class__, self)._run()
597
        self._run()
598

    
599

    
600
@command(image_cmds)
601
class image_compute_info(_init_cyclades, _optional_json):
602
    """Get detailed information on an image"""
603

    
604
    @errors.generic.all
605
    @errors.cyclades.connection
606
    @errors.plankton.id
607
    def _run(self, image_id):
608
        image = self.client.get_image_details(image_id)
609
        self._print(image, print_dict)
610

    
611
    def main(self, image_id):
612
        super(self.__class__, self)._run()
613
        self._run(image_id=image_id)
614

    
615

    
616
@command(image_cmds)
617
class image_compute_delete(_init_cyclades, _optional_output_cmd):
618
    """Delete an image (WARNING: image file is also removed)"""
619

    
620
    @errors.generic.all
621
    @errors.cyclades.connection
622
    @errors.plankton.id
623
    def _run(self, image_id):
624
        self._optional_output(self.client.delete_image(image_id))
625

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

    
630

    
631
@command(image_cmds)
632
class image_compute_properties(_init_cyclades):
633
    """Manage properties related to OS installation in an image"""
634

    
635

    
636
@command(image_cmds)
637
class image_compute_properties_list(_init_cyclades, _optional_json):
638
    """List all image properties"""
639

    
640
    @errors.generic.all
641
    @errors.cyclades.connection
642
    @errors.plankton.id
643
    def _run(self, image_id):
644
        self._print(self.client.get_image_metadata(image_id), print_dict)
645

    
646
    def main(self, image_id):
647
        super(self.__class__, self)._run()
648
        self._run(image_id=image_id)
649

    
650

    
651
@command(image_cmds)
652
class image_compute_properties_get(_init_cyclades, _optional_json):
653
    """Get an image property"""
654

    
655
    @errors.generic.all
656
    @errors.cyclades.connection
657
    @errors.plankton.id
658
    @errors.plankton.metadata
659
    def _run(self, image_id, key):
660
        self._print(self.client.get_image_metadata(image_id, key), print_dict)
661

    
662
    def main(self, image_id, key):
663
        super(self.__class__, self)._run()
664
        self._run(image_id=image_id, key=key)
665

    
666

    
667
@command(image_cmds)
668
class image_compute_properties_add(_init_cyclades, _optional_json):
669
    """Add a property to an image"""
670

    
671
    @errors.generic.all
672
    @errors.cyclades.connection
673
    @errors.plankton.id
674
    @errors.plankton.metadata
675
    def _run(self, image_id, key, val):
676
        self._print(
677
            self.client.create_image_metadata(image_id, key, val), print_dict)
678

    
679
    def main(self, image_id, key, val):
680
        super(self.__class__, self)._run()
681
        self._run(image_id=image_id, key=key, val=val)
682

    
683

    
684
@command(image_cmds)
685
class image_compute_properties_set(_init_cyclades, _optional_json):
686
    """Add / update a set of properties for an image
687
    proeprties must be given in the form key=value, e.v.
688
    /image compute properties set <image-id> key1=val1 key2=val2
689
    """
690

    
691
    @errors.generic.all
692
    @errors.cyclades.connection
693
    @errors.plankton.id
694
    def _run(self, image_id, keyvals):
695
        meta = dict()
696
        for keyval in keyvals:
697
            key, val = keyval.split('=')
698
            meta[key] = val
699
        self._print(
700
            self.client.update_image_metadata(image_id, **meta), print_dict)
701

    
702
    def main(self, image_id, *key_equals_value):
703
        super(self.__class__, self)._run()
704
        self._run(image_id=image_id, keyvals=key_equals_value)
705

    
706

    
707
@command(image_cmds)
708
class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
709
    """Delete a property from an image"""
710

    
711
    @errors.generic.all
712
    @errors.cyclades.connection
713
    @errors.plankton.id
714
    @errors.plankton.metadata
715
    def _run(self, image_id, key):
716
        self._optional_output(self.client.delete_image_metadata(image_id, key))
717

    
718
    def main(self, image_id, key):
719
        super(self.__class__, self)._run()
720
        self._run(image_id=image_id, key=key)