Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (23.2 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
        self.auth_base.term('uuid', atoken)
312

    
313
    def _get_pithos_client(self, container):
314
        if self['no_metafile_upload']:
315
            return None
316
        pithos_endpoints = self.auth_base.get_service_endpoints(
317
            self.config.get('pithos', 'type'),
318
            self.config.get('pithos', 'version'))
319
        purl = pithos_endpoints['publicURL']
320
        ptoken = self.client.token
321
        return PithosClient(purl, ptoken, self._get_uuid(), container)
322

    
323
    def _store_remote_metafile(self, pclient, remote_path, metadata):
324
        return pclient.upload_from_string(
325
            remote_path, _validate_image_meta(metadata, return_str=True))
326

    
327
    def _load_params_from_file(self, location):
328
        params, properties = dict(), dict()
329
        pfile = self['metafile']
330
        if pfile:
331
            try:
332
                for k, v in _load_image_meta(pfile).items():
333
                    key = k.lower().replace('-', '_')
334
                    if k == 'properties':
335
                        for pk, pv in v.items():
336
                            properties[pk.upper().replace('-', '_')] = pv
337
                    elif key == 'name':
338
                            continue
339
                    elif key == 'location':
340
                        if location:
341
                            continue
342
                        location = v
343
                    else:
344
                        params[key] = v
345
            except Exception as e:
346
                raiseCLIError(e, 'Invalid json metadata config file')
347
        return params, properties, location
348

    
349
    def _load_params_from_args(self, params, properties):
350
        for key in set([
351
                'checksum',
352
                'container_format',
353
                'disk_format',
354
                'owner',
355
                'size',
356
                'is_public']).intersection(self.arguments):
357
            params[key] = self[key]
358
        for k, v in self['properties'].items():
359
            properties[k.upper().replace('-', '_')] = v
360

    
361
    def _validate_location(self, location):
362
        if not location:
363
            raiseCLIError(
364
                'No image file location provided',
365
                importance=2, details=[
366
                    'An image location is needed. Image location format:',
367
                    '  pithos://<uuid>/<container>/<path>',
368
                    ' an image file at the above location must exist.'
369
                    ] + howto_image_file)
370
        try:
371
            return _validate_image_location(location)
372
        except AssertionError as ae:
373
            raiseCLIError(
374
                ae, 'Invalid image location format',
375
                importance=1, details=[
376
                    'Valid image location format:',
377
                    '  pithos://<uuid>/<container>/<img-file-path>'
378
                    ] + howto_image_file)
379

    
380
    @errors.generic.all
381
    @errors.plankton.connection
382
    def _run(self, name, location):
383
        (params, properties, location) = self._load_params_from_file(location)
384
        uuid, container, img_path = self._validate_location(location)
385
        self._load_params_from_args(params, properties)
386
        pclient = self._get_pithos_client(container)
387

    
388
        #check if metafile exists
389
        meta_path = '%s.meta' % img_path
390
        if pclient and not self['metafile_force']:
391
            try:
392
                pclient.get_object_info(meta_path)
393
                raiseCLIError('Metadata file %s:%s already exists' % (
394
                    container, meta_path))
395
            except ClientError as ce:
396
                if ce.status != 404:
397
                    raise
398

    
399
        #register the image
400
        try:
401
            r = self.client.register(name, location, params, properties)
402
        except ClientError as ce:
403
            if ce.status in (400, ):
404
                raiseCLIError(
405
                    ce, 'Nonexistent image file location %s' % location,
406
                    details=[
407
                        'Make sure the image file exists'] + howto_image_file)
408
            raise
409
        self._print(r, print_dict)
410

    
411
        #upload the metadata file
412
        if pclient:
413
            try:
414
                meta_headers = pclient.upload_from_string(
415
                    meta_path, dumps(r, indent=2))
416
            except TypeError:
417
                print('Failed to dump metafile %s:%s' % (container, meta_path))
418
                return
419
            if self['json_output']:
420
                print_json(dict(
421
                    metafile_location='%s:%s' % (container, meta_path),
422
                    headers=meta_headers))
423
            else:
424
                print('Metadata file uploaded as %s:%s (version %s)' % (
425
                    container, meta_path, meta_headers['x-object-version']))
426

    
427
    def main(self, name, location=None):
428
        super(self.__class__, self)._run()
429
        self._run(name, location)
430

    
431

    
432
@command(image_cmds)
433
class image_unregister(_init_image, _optional_output_cmd):
434
    """Unregister an image (does not delete the image file)"""
435

    
436
    @errors.generic.all
437
    @errors.plankton.connection
438
    @errors.plankton.id
439
    def _run(self, image_id):
440
        self._optional_output(self.client.unregister(image_id))
441

    
442
    def main(self, image_id):
443
        super(self.__class__, self)._run()
444
        self._run(image_id=image_id)
445

    
446

    
447
@command(image_cmds)
448
class image_shared(_init_image, _optional_json):
449
    """List images shared by a member"""
450

    
451
    @errors.generic.all
452
    @errors.plankton.connection
453
    def _run(self, member):
454
        self._print(self.client.list_shared(member), title=('image_id',))
455

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

    
460

    
461
@command(image_cmds)
462
class image_members(_init_image):
463
    """Manage members. Members of an image are users who can modify it"""
464

    
465

    
466
@command(image_cmds)
467
class image_members_list(_init_image, _optional_json):
468
    """List members of an image"""
469

    
470
    @errors.generic.all
471
    @errors.plankton.connection
472
    @errors.plankton.id
473
    def _run(self, image_id):
474
        self._print(self.client.list_members(image_id), title=('member_id',))
475

    
476
    def main(self, image_id):
477
        super(self.__class__, self)._run()
478
        self._run(image_id=image_id)
479

    
480

    
481
@command(image_cmds)
482
class image_members_add(_init_image, _optional_output_cmd):
483
    """Add a member to an image"""
484

    
485
    @errors.generic.all
486
    @errors.plankton.connection
487
    @errors.plankton.id
488
    def _run(self, image_id=None, member=None):
489
            self._optional_output(self.client.add_member(image_id, member))
490

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

    
495

    
496
@command(image_cmds)
497
class image_members_delete(_init_image, _optional_output_cmd):
498
    """Remove a member from an image"""
499

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

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

    
510

    
511
@command(image_cmds)
512
class image_members_set(_init_image, _optional_output_cmd):
513
    """Set the members of an image"""
514

    
515
    @errors.generic.all
516
    @errors.plankton.connection
517
    @errors.plankton.id
518
    def _run(self, image_id, members):
519
            self._optional_output(self.client.set_members(image_id, members))
520

    
521
    def main(self, image_id, *members):
522
        super(self.__class__, self)._run()
523
        self._run(image_id=image_id, members=members)
524

    
525

    
526
# Compute Image Commands
527

    
528

    
529
@command(image_cmds)
530
class image_compute(_init_cyclades):
531
    """Cyclades/Compute API image commands"""
532

    
533

    
534
@command(image_cmds)
535
class image_compute_list(_init_cyclades, _optional_json):
536
    """List images"""
537

    
538
    arguments = dict(
539
        detail=FlagArgument('show detailed output', ('-l', '--details')),
540
        limit=IntArgument('limit number listed images', ('-n', '--number')),
541
        more=FlagArgument(
542
            'output results in pages (-n to set items per page, default 10)',
543
            '--more'),
544
        enum=FlagArgument('Enumerate results', '--enumerate')
545
    )
546

    
547
    @errors.generic.all
548
    @errors.cyclades.connection
549
    def _run(self):
550
        images = self.client.list_images(self['detail'])
551
        kwargs = dict(with_enumeration=self['enum'])
552
        if self['more']:
553
            kwargs['page_size'] = self['limit'] or 10
554
        elif self['limit']:
555
            images = images[:self['limit']]
556
        self._print(images, **kwargs)
557

    
558
    def main(self):
559
        super(self.__class__, self)._run()
560
        self._run()
561

    
562

    
563
@command(image_cmds)
564
class image_compute_info(_init_cyclades, _optional_json):
565
    """Get detailed information on an image"""
566

    
567
    @errors.generic.all
568
    @errors.cyclades.connection
569
    @errors.plankton.id
570
    def _run(self, image_id):
571
        image = self.client.get_image_details(image_id)
572
        self._print(image, print_dict)
573

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

    
578

    
579
@command(image_cmds)
580
class image_compute_delete(_init_cyclades, _optional_output_cmd):
581
    """Delete an image (WARNING: image file is also removed)"""
582

    
583
    @errors.generic.all
584
    @errors.cyclades.connection
585
    @errors.plankton.id
586
    def _run(self, image_id):
587
        self._optional_output(self.client.delete_image(image_id))
588

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

    
593

    
594
@command(image_cmds)
595
class image_compute_properties(_init_cyclades):
596
    """Manage properties related to OS installation in an image"""
597

    
598

    
599
@command(image_cmds)
600
class image_compute_properties_list(_init_cyclades, _optional_json):
601
    """List all image properties"""
602

    
603
    @errors.generic.all
604
    @errors.cyclades.connection
605
    @errors.plankton.id
606
    def _run(self, image_id):
607
        self._print(self.client.get_image_metadata(image_id), print_dict)
608

    
609
    def main(self, image_id):
610
        super(self.__class__, self)._run()
611
        self._run(image_id=image_id)
612

    
613

    
614
@command(image_cmds)
615
class image_compute_properties_get(_init_cyclades, _optional_json):
616
    """Get an image property"""
617

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

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

    
629

    
630
@command(image_cmds)
631
class image_compute_properties_add(_init_cyclades, _optional_json):
632
    """Add a property to an image"""
633

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

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

    
646

    
647
@command(image_cmds)
648
class image_compute_properties_set(_init_cyclades, _optional_json):
649
    """Add / update a set of properties for an image
650
    proeprties must be given in the form key=value, e.v.
651
    /image compute properties set <image-id> key1=val1 key2=val2
652
    """
653

    
654
    @errors.generic.all
655
    @errors.cyclades.connection
656
    @errors.plankton.id
657
    def _run(self, image_id, keyvals):
658
        meta = dict()
659
        for keyval in keyvals:
660
            key, val = keyval.split('=')
661
            meta[key] = val
662
        self._print(
663
            self.client.update_image_metadata(image_id, **meta), print_dict)
664

    
665
    def main(self, image_id, *key_equals_value):
666
        super(self.__class__, self)._run()
667
        self._run(image_id=image_id, keyvals=key_equals_value)
668

    
669

    
670
@command(image_cmds)
671
class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
672
    """Delete a property from an image"""
673

    
674
    @errors.generic.all
675
    @errors.cyclades.connection
676
    @errors.plankton.id
677
    @errors.plankton.metadata
678
    def _run(self, image_id, key):
679
        self._optional_output(self.client.delete_image_metadata(image_id, key))
680

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