Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (23.1 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
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('compute', 'token')\
79
            or self.config.get('global', 'token')
80
        plankton_endpoints = self.auth_base.get_service_endpoints(
81
            self.config.get('plankton', 'type'),
82
            self.config.get('plankton', 'version'))
83
        base_url = plankton_endpoints['publicURL']
84
        base_url = self.config.get('image', 'url')\
85
            or self.config.get('compute', 'url')\
86
            or self.config.get('global', 'url')
87
        self.client = ImageClient(base_url=base_url, token=token)
88
        self._set_log_params()
89
        self._update_max_threads()
90

    
91
    def main(self):
92
        self._run()
93

    
94

    
95
# Plankton Image Commands
96

    
97

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

103
    :param return_str: (boolean) if true, return a json dump
104

105
    :returns: (dict) if return_str is not True, else return str
106

107
    :raises TypeError, AttributeError: Invalid json format
108

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

    
127

    
128
def _load_image_meta(filepath):
129
    """
130
    :param filepath: (str) the (relative) path of the metafile
131

132
    :returns: (dict) json_formated
133

134
    :raises TypeError, AttributeError: Invalid json format
135

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

    
146

    
147
def _validate_image_location(location):
148
    """
149
    :param location: (str) pithos://<uuid>/<container>/<img-file-path>
150

151
    :returns: (<uuid>, <container>, <img-file-path>)
152

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

    
166

    
167
@command(image_cmds)
168
class image_list(_init_image, _optional_json):
169
    """List images accessible by user"""
170

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

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

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

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

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

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

    
250
    def main(self):
251
        super(self.__class__, self)._run()
252
        self._run()
253

    
254

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

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

    
270
    def main(self, image_id):
271
        super(self.__class__, self)._run()
272
        self._run(image_id=image_id)
273

    
274

    
275
@command(image_cmds)
276
class image_register(_init_image, _optional_json):
277
    """(Re)Register an image"""
278

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

    
302
    )
303

    
304
    def _get_uuid(self):
305
        atoken = self.client.token
306
        user = AstakosClient(self.config.get('user', 'url'), atoken)
307
        return user.term('uuid')
308

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

    
319
    def _store_remote_metafile(self, pclient, remote_path, metadata):
320
        return pclient.upload_from_string(
321
            remote_path, _validate_image_meta(metadata, return_str=True))
322

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

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

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

    
376
    @errors.generic.all
377
    @errors.plankton.connection
378
    def _run(self, name, location):
379
        (params, properties, location) = self._load_params_from_file(location)
380
        uuid, container, img_path = self._validate_location(location)
381
        self._load_params_from_args(params, properties)
382
        pclient = self._get_pithos_client(container)
383

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

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

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

    
423
    def main(self, name, location=None):
424
        super(self.__class__, self)._run()
425
        self._run(name, location)
426

    
427

    
428
@command(image_cmds)
429
class image_unregister(_init_image, _optional_output_cmd):
430
    """Unregister an image (does not delete the image file)"""
431

    
432
    @errors.generic.all
433
    @errors.plankton.connection
434
    @errors.plankton.id
435
    def _run(self, image_id):
436
        self._optional_output(self.client.unregister(image_id))
437

    
438
    def main(self, image_id):
439
        super(self.__class__, self)._run()
440
        self._run(image_id=image_id)
441

    
442

    
443
@command(image_cmds)
444
class image_shared(_init_image, _optional_json):
445
    """List images shared by a member"""
446

    
447
    @errors.generic.all
448
    @errors.plankton.connection
449
    def _run(self, member):
450
        self._print(self.client.list_shared(member), title=('image_id',))
451

    
452
    def main(self, member):
453
        super(self.__class__, self)._run()
454
        self._run(member)
455

    
456

    
457
@command(image_cmds)
458
class image_members(_init_image):
459
    """Manage members. Members of an image are users who can modify it"""
460

    
461

    
462
@command(image_cmds)
463
class image_members_list(_init_image, _optional_json):
464
    """List members of an image"""
465

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

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

    
476

    
477
@command(image_cmds)
478
class image_members_add(_init_image, _optional_output_cmd):
479
    """Add a member to an image"""
480

    
481
    @errors.generic.all
482
    @errors.plankton.connection
483
    @errors.plankton.id
484
    def _run(self, image_id=None, member=None):
485
            self._optional_output(self.client.add_member(image_id, member))
486

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

    
491

    
492
@command(image_cmds)
493
class image_members_delete(_init_image, _optional_output_cmd):
494
    """Remove a member from an image"""
495

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

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

    
506

    
507
@command(image_cmds)
508
class image_members_set(_init_image, _optional_output_cmd):
509
    """Set the members of an image"""
510

    
511
    @errors.generic.all
512
    @errors.plankton.connection
513
    @errors.plankton.id
514
    def _run(self, image_id, members):
515
            self._optional_output(self.client.set_members(image_id, members))
516

    
517
    def main(self, image_id, *members):
518
        super(self.__class__, self)._run()
519
        self._run(image_id=image_id, members=members)
520

    
521

    
522
# Compute Image Commands
523

    
524

    
525
@command(image_cmds)
526
class image_compute(_init_cyclades):
527
    """Cyclades/Compute API image commands"""
528

    
529

    
530
@command(image_cmds)
531
class image_compute_list(_init_cyclades, _optional_json):
532
    """List images"""
533

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

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

    
554
    def main(self):
555
        super(self.__class__, self)._run()
556
        self._run()
557

    
558

    
559
@command(image_cmds)
560
class image_compute_info(_init_cyclades, _optional_json):
561
    """Get detailed information on an image"""
562

    
563
    @errors.generic.all
564
    @errors.cyclades.connection
565
    @errors.plankton.id
566
    def _run(self, image_id):
567
        image = self.client.get_image_details(image_id)
568
        self._print(image, print_dict)
569

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

    
574

    
575
@command(image_cmds)
576
class image_compute_delete(_init_cyclades, _optional_output_cmd):
577
    """Delete an image (WARNING: image file is also removed)"""
578

    
579
    @errors.generic.all
580
    @errors.cyclades.connection
581
    @errors.plankton.id
582
    def _run(self, image_id):
583
        self._optional_output(self.client.delete_image(image_id))
584

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

    
589

    
590
@command(image_cmds)
591
class image_compute_properties(_init_cyclades):
592
    """Manage properties related to OS installation in an image"""
593

    
594

    
595
@command(image_cmds)
596
class image_compute_properties_list(_init_cyclades, _optional_json):
597
    """List all image properties"""
598

    
599
    @errors.generic.all
600
    @errors.cyclades.connection
601
    @errors.plankton.id
602
    def _run(self, image_id):
603
        self._print(self.client.get_image_metadata(image_id), print_dict)
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_get(_init_cyclades, _optional_json):
612
    """Get an image property"""
613

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

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

    
625

    
626
@command(image_cmds)
627
class image_compute_properties_add(_init_cyclades, _optional_json):
628
    """Add a property to an image"""
629

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

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

    
642

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

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

    
661
    def main(self, image_id, *key_equals_value):
662
        super(self.__class__, self)._run()
663
        self._run(image_id=image_id, keyvals=key_equals_value)
664

    
665

    
666
@command(image_cmds)
667
class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
668
    """Delete a property from an image"""
669

    
670
    @errors.generic.all
671
    @errors.cyclades.connection
672
    @errors.plankton.id
673
    @errors.plankton.metadata
674
    def _run(self, image_id, key):
675
        self._optional_output(self.client.delete_image_metadata(image_id, key))
676

    
677
    def main(self, image_id, key):
678
        super(self.__class__, self)._run()
679
        self._run(image_id=image_id, key=key)