Statistics
| Branch: | Tag: | Revision:

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

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
        base_url = self.config.get('image', 'url')\
81
            or self.config.get('compute', 'url')\
82
            or self.config.get('global', 'url')
83
        self.client = ImageClient(base_url=base_url, token=token)
84
        self._set_log_params()
85
        self._update_max_threads()
86

    
87
    def main(self):
88
        self._run()
89

    
90

    
91
# Plankton Image Commands
92

    
93

    
94
def _validate_image_meta(json_dict, return_str=False):
95
    """
96
    :param json_dict" (dict) json-formated, of the form
97
        {"key1": "val1", "key2": "val2", ...}
98

99
    :param return_str: (boolean) if true, return a json dump
100

101
    :returns: (dict) if return_str is not True, else return str
102

103
    :raises TypeError, AttributeError: Invalid json format
104

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

    
123

    
124
def _load_image_meta(filepath):
125
    """
126
    :param filepath: (str) the (relative) path of the metafile
127

128
    :returns: (dict) json_formated
129

130
    :raises TypeError, AttributeError: Invalid json format
131

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

    
142

    
143
def _validate_image_location(location):
144
    """
145
    :param location: (str) pithos://<uuid>/<container>/<img-file-path>
146

147
    :returns: (<uuid>, <container>, <img-file-path>)
148

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

    
162

    
163
@command(image_cmds)
164
class image_list(_init_image, _optional_json):
165
    """List images accessible by user"""
166

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

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

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

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

    
231
        order = self['order']
232
        detail = self['detail']
233
        if self['owner']:
234
            images = self._filtered_by_owner(detail, filters, order)
235
        else:
236
            images = self.client.list_public(detail, filters, order)
237

    
238
        images = self._filtered_by_name(images)
239
        kwargs = dict(with_enumeration=self['enum'])
240
        if self['more']:
241
            kwargs['page_size'] = self['limit'] or 10
242
        elif self['limit']:
243
            images = images[:self['limit']]
244
        self._print(images, **kwargs)
245

    
246
    def main(self):
247
        super(self.__class__, self)._run()
248
        self._run()
249

    
250

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

    
260
    @errors.generic.all
261
    @errors.plankton.connection
262
    @errors.plankton.id
263
    def _run(self, image_id):
264
        self._print([self.client.get_meta(image_id)])
265

    
266
    def main(self, image_id):
267
        super(self.__class__, self)._run()
268
        self._run(image_id=image_id)
269

    
270

    
271
@command(image_cmds)
272
class image_register(_init_image, _optional_json):
273
    """(Re)Register an image"""
274

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

    
298
    )
299

    
300
    def _get_uuid(self):
301
        atoken = self.client.token
302
        user = AstakosClient(self.config.get('user', 'url'), atoken)
303
        return user.term('uuid')
304

    
305
    def _get_pithos_client(self, container):
306
        if self['no_metafile_upload']:
307
            return None
308
        purl = self.config.get('file', 'url')
309
        ptoken = self.client.token
310
        return PithosClient(purl, ptoken, self._get_uuid(), container)
311

    
312
    def _store_remote_metafile(self, pclient, remote_path, metadata):
313
        return pclient.upload_from_string(
314
            remote_path, _validate_image_meta(metadata, return_str=True))
315

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

    
338
    def _load_params_from_args(self, params, properties):
339
        for key in set([
340
                'checksum',
341
                'container_format',
342
                'disk_format',
343
                'owner',
344
                'size',
345
                'is_public']).intersection(self.arguments):
346
            params[key] = self[key]
347
        for k, v in self['properties'].items():
348
            properties[k.upper().replace('-', '_')] = v
349

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

    
369
    @errors.generic.all
370
    @errors.plankton.connection
371
    def _run(self, name, location):
372
        (params, properties, location) = self._load_params_from_file(location)
373
        uuid, container, img_path = self._validate_location(location)
374
        self._load_params_from_args(params, properties)
375
        pclient = self._get_pithos_client(container)
376

    
377
        #check if metafile exists
378
        meta_path = '%s.meta' % img_path
379
        if pclient and not self['metafile_force']:
380
            try:
381
                pclient.get_object_info(meta_path)
382
                raiseCLIError('Metadata file %s:%s already exists' % (
383
                    container, meta_path))
384
            except ClientError as ce:
385
                if ce.status != 404:
386
                    raise
387

    
388
        #register the image
389
        try:
390
            r = self.client.register(name, location, params, properties)
391
        except ClientError as ce:
392
            if ce.status in (400, ):
393
                raiseCLIError(
394
                    ce, 'Nonexistent image file location %s' % location,
395
                    details=[
396
                        'Make sure the image file exists'] + howto_image_file)
397
            raise
398
        self._print(r, print_dict)
399

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

    
416
    def main(self, name, location=None):
417
        super(self.__class__, self)._run()
418
        self._run(name, location)
419

    
420

    
421
@command(image_cmds)
422
class image_unregister(_init_image, _optional_output_cmd):
423
    """Unregister an image (does not delete the image file)"""
424

    
425
    @errors.generic.all
426
    @errors.plankton.connection
427
    @errors.plankton.id
428
    def _run(self, image_id):
429
        self._optional_output(self.client.unregister(image_id))
430

    
431
    def main(self, image_id):
432
        super(self.__class__, self)._run()
433
        self._run(image_id=image_id)
434

    
435

    
436
@command(image_cmds)
437
class image_shared(_init_image, _optional_json):
438
    """List images shared by a member"""
439

    
440
    @errors.generic.all
441
    @errors.plankton.connection
442
    def _run(self, member):
443
        self._print(self.client.list_shared(member), title=('image_id',))
444

    
445
    def main(self, member):
446
        super(self.__class__, self)._run()
447
        self._run(member)
448

    
449

    
450
@command(image_cmds)
451
class image_members(_init_image):
452
    """Manage members. Members of an image are users who can modify it"""
453

    
454

    
455
@command(image_cmds)
456
class image_members_list(_init_image, _optional_json):
457
    """List members of an image"""
458

    
459
    @errors.generic.all
460
    @errors.plankton.connection
461
    @errors.plankton.id
462
    def _run(self, image_id):
463
        self._print(self.client.list_members(image_id), title=('member_id',))
464

    
465
    def main(self, image_id):
466
        super(self.__class__, self)._run()
467
        self._run(image_id=image_id)
468

    
469

    
470
@command(image_cmds)
471
class image_members_add(_init_image, _optional_output_cmd):
472
    """Add a member to an image"""
473

    
474
    @errors.generic.all
475
    @errors.plankton.connection
476
    @errors.plankton.id
477
    def _run(self, image_id=None, member=None):
478
            self._optional_output(self.client.add_member(image_id, member))
479

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

    
484

    
485
@command(image_cmds)
486
class image_members_delete(_init_image, _optional_output_cmd):
487
    """Remove a member from an image"""
488

    
489
    @errors.generic.all
490
    @errors.plankton.connection
491
    @errors.plankton.id
492
    def _run(self, image_id=None, member=None):
493
            self._optional_output(self.client.remove_member(image_id, member))
494

    
495
    def main(self, image_id, member):
496
        super(self.__class__, self)._run()
497
        self._run(image_id=image_id, member=member)
498

    
499

    
500
@command(image_cmds)
501
class image_members_set(_init_image, _optional_output_cmd):
502
    """Set the members of an image"""
503

    
504
    @errors.generic.all
505
    @errors.plankton.connection
506
    @errors.plankton.id
507
    def _run(self, image_id, members):
508
            self._optional_output(self.client.set_members(image_id, members))
509

    
510
    def main(self, image_id, *members):
511
        super(self.__class__, self)._run()
512
        self._run(image_id=image_id, members=members)
513

    
514

    
515
# Compute Image Commands
516

    
517

    
518
@command(image_cmds)
519
class image_compute(_init_cyclades):
520
    """Cyclades/Compute API image commands"""
521

    
522

    
523
@command(image_cmds)
524
class image_compute_list(_init_cyclades, _optional_json):
525
    """List images"""
526

    
527
    arguments = dict(
528
        detail=FlagArgument('show detailed output', ('-l', '--details')),
529
        limit=IntArgument('limit number listed images', ('-n', '--number')),
530
        more=FlagArgument(
531
            'output results in pages (-n to set items per page, default 10)',
532
            '--more'),
533
        enum=FlagArgument('Enumerate results', '--enumerate')
534
    )
535

    
536
    def _make_results_pretty(self, images):
537
        for img in images:
538
            if 'metadata' in img:
539
                img['metadata'] = img['metadata']['values']
540

    
541
    @errors.generic.all
542
    @errors.cyclades.connection
543
    def _run(self):
544
        images = self.client.list_images(self['detail'])
545
        if self['detail'] and not self['json_output']:
546
            self._make_results_pretty(images)
547
        kwargs = dict(with_enumeration=self['enum'])
548
        if self['more']:
549
            kwargs['page_size'] = self['limit'] or 10
550
        else:
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
        if (not self['json_output']) and 'metadata' in image:
569
            image['metadata'] = image['metadata']['values']
570
        self._print([image])
571

    
572
    def main(self, image_id):
573
        super(self.__class__, self)._run()
574
        self._run(image_id=image_id)
575

    
576

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

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

    
587
    def main(self, image_id):
588
        super(self.__class__, self)._run()
589
        self._run(image_id=image_id)
590

    
591

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

    
596

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

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

    
607
    def main(self, image_id):
608
        super(self.__class__, self)._run()
609
        self._run(image_id=image_id)
610

    
611

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

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

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

    
627

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

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

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

    
644

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

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

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

    
667

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

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

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