Fix PEP8 warning
[kamaki] / kamaki / cli / commands / image.py
1 # Copyright 2012 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.command
33
34 from json import load, dumps
35 from os.path import abspath
36 from logging import getLogger
37
38 from kamaki.cli import command
39 from kamaki.cli.command_tree import CommandTree
40 from kamaki.cli.utils import print_dict, print_json
41 from kamaki.clients.image import ImageClient
42 from kamaki.clients.pithos import PithosClient
43 from kamaki.clients.astakos import AstakosClient
44 from kamaki.clients import ClientError
45 from kamaki.cli.argument import FlagArgument, ValueArgument, KeyValueArgument
46 from kamaki.cli.argument import IntArgument
47 from kamaki.cli.commands.cyclades import _init_cyclades
48 from kamaki.cli.errors import raiseCLIError, CLIBaseUrlError
49 from kamaki.cli.commands import _command_init, errors, addLogSettings
50 from kamaki.cli.commands import _optional_output_cmd, _optional_json
51
52
53 image_cmds = CommandTree(
54     'image',
55     'Cyclades/Plankton API image commands\n'
56     'image compute:\tCyclades/Compute API image commands')
57 _commands = [image_cmds]
58
59
60 howto_image_file = [
61     'Kamaki commands to:',
62     ' get current user id: /user authenticate',
63     ' check available containers: /file list',
64     ' create a new container: /file create <container>',
65     ' check container contents: /file list <container>',
66     ' upload files: /file upload <image file> <container>']
67
68 about_image_id = ['To see a list of available image ids: /image list']
69
70
71 log = getLogger(__name__)
72
73
74 class _init_image(_command_init):
75     @errors.generic.all
76     @addLogSettings
77     def _run(self):
78         if getattr(self, 'cloud', None):
79             img_url = self._custom_url('image') or self._custom_url('plankton')
80             if img_url:
81                 token = self._custom_token('image')\
82                     or self._custom_token('plankton')\
83                     or self.config.get_remote(self.cloud, 'token')
84                 self.client = ImageClient(base_url=img_url, token=token)
85                 return
86         if getattr(self, 'auth_base', False):
87             plankton_endpoints = self.auth_base.get_service_endpoints(
88                 self._custom_type('image') 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>/<img-file-path>
157
158     :returns: (<user-id>, <container>, <img-file-path>)
159
160     :raises AssertionError: if location is invalid
161     """
162     prefix = 'pithos://'
163     msg = 'Invalid prefix for location %s , try: %s' % (location, prefix)
164     assert location.startswith(prefix), msg
165     service, sep, rest = location.partition('://')
166     assert sep and rest, 'Location %s is missing user-id' % location
167     uuid, sep, rest = rest.partition('/')
168     assert sep and rest, 'Location %s is missing container' % location
169     container, sep, img_path = rest.partition('/')
170     assert sep and img_path, 'Location %s is missing image path' % location
171     return uuid, container, img_path
172
173
174 @command(image_cmds)
175 class image_list(_init_image, _optional_json):
176     """List images accessible by user"""
177
178     arguments = dict(
179         detail=FlagArgument('show detailed output', ('-l', '--details')),
180         container_format=ValueArgument(
181             'filter by container format',
182             '--container-format'),
183         disk_format=ValueArgument('filter by disk format', '--disk-format'),
184         name=ValueArgument('filter by name', '--name'),
185         name_pref=ValueArgument(
186             'filter by name prefix (case insensitive)',
187             '--name-prefix'),
188         name_suff=ValueArgument(
189             'filter by name suffix (case insensitive)',
190             '--name-suffix'),
191         name_like=ValueArgument(
192             'print only if name contains this (case insensitive)',
193             '--name-like'),
194         size_min=IntArgument('filter by minimum size', '--size-min'),
195         size_max=IntArgument('filter by maximum size', '--size-max'),
196         status=ValueArgument('filter by status', '--status'),
197         owner=ValueArgument('filter by owner', '--owner'),
198         order=ValueArgument(
199             'order by FIELD ( - to reverse order)',
200             '--order',
201             default=''),
202         limit=IntArgument('limit number of listed images', ('-n', '--number')),
203         more=FlagArgument(
204             'output results in pages (-n to set items per page, default 10)',
205             '--more'),
206         enum=FlagArgument('Enumerate results', '--enumerate')
207     )
208
209     def _filtered_by_owner(self, detail, *list_params):
210         images = []
211         MINKEYS = set([
212             'id', 'size', 'status', 'disk_format', 'container_format', 'name'])
213         for img in self.client.list_public(True, *list_params):
214             if img['owner'] == self['owner']:
215                 if not detail:
216                     for key in set(img.keys()).difference(MINKEYS):
217                         img.pop(key)
218                 images.append(img)
219         return images
220
221     def _filtered_by_name(self, images):
222         np, ns, nl = self['name_pref'], self['name_suff'], self['name_like']
223         return [img for img in images if (
224             (not np) or img['name'].lower().startswith(np.lower())) and (
225             (not ns) or img['name'].lower().endswith(ns.lower())) and (
226             (not nl) or nl.lower() in img['name'].lower())]
227
228     @errors.generic.all
229     @errors.cyclades.connection
230     def _run(self):
231         super(self.__class__, self)._run()
232         filters = {}
233         for arg in set([
234                 'container_format',
235                 'disk_format',
236                 'name',
237                 'size_min',
238                 'size_max',
239                 'status']).intersection(self.arguments):
240             filters[arg] = self[arg]
241
242         order = self['order']
243         detail = self['detail']
244         if self['owner']:
245             images = self._filtered_by_owner(detail, filters, order)
246         else:
247             images = self.client.list_public(detail, filters, order)
248
249         images = self._filtered_by_name(images)
250         kwargs = dict(with_enumeration=self['enum'])
251         if self['more']:
252             kwargs['page_size'] = self['limit'] or 10
253         elif self['limit']:
254             images = images[:self['limit']]
255         self._print(images, **kwargs)
256
257     def main(self):
258         super(self.__class__, self)._run()
259         self._run()
260
261
262 @command(image_cmds)
263 class image_meta(_init_image, _optional_json):
264     """Get image metadata
265     Image metadata include:
266     - image file information (location, size, etc.)
267     - image information (id, name, etc.)
268     - image os properties (os, fs, etc.)
269     """
270
271     @errors.generic.all
272     @errors.plankton.connection
273     @errors.plankton.id
274     def _run(self, image_id):
275         self._print([self.client.get_meta(image_id)])
276
277     def main(self, image_id):
278         super(self.__class__, self)._run()
279         self._run(image_id=image_id)
280
281
282 @command(image_cmds)
283 class image_register(_init_image, _optional_json):
284     """(Re)Register an image"""
285
286     arguments = dict(
287         checksum=ValueArgument('set image checksum', '--checksum'),
288         container_format=ValueArgument(
289             'set container format',
290             '--container-format'),
291         disk_format=ValueArgument('set disk format', '--disk-format'),
292         owner=ValueArgument('set image owner (admin only)', '--owner'),
293         properties=KeyValueArgument(
294             'add property in key=value form (can be repeated)',
295             ('-p', '--property')),
296         is_public=FlagArgument('mark image as public', '--public'),
297         size=IntArgument('set image size', '--size'),
298         metafile=ValueArgument(
299             'Load metadata from a json-formated file <img-file>.meta :'
300             '{"key1": "val1", "key2": "val2", ..., "properties: {...}"}',
301             ('--metafile')),
302         metafile_force=FlagArgument(
303             'Store remote metadata object, even if it already exists',
304             ('-f', '--force')),
305         no_metafile_upload=FlagArgument(
306             'Do not store metadata in remote meta file',
307             ('--no-metafile-upload')),
308
309     )
310
311     def _get_user_id(self):
312         atoken = self.client.token
313         if getattr(self, 'auth_base', False):
314             return self.auth_base.term('id', atoken)
315         else:
316             astakos_url = self.config.get('user', 'url')\
317                 or self.config.get('astakos', 'url')
318             if not astakos_url:
319                 raise CLIBaseUrlError(service='astakos')
320             user = AstakosClient(astakos_url, atoken)
321             return user.term('id')
322
323     def _get_pithos_client(self, container):
324         if self['no_metafile_upload']:
325             return None
326         ptoken = self.client.token
327         if getattr(self, 'auth_base', False):
328             pithos_endpoints = self.auth_base.get_service_endpoints(
329                 self.config.get('pithos', 'type'),
330                 self.config.get('pithos', 'version'))
331             purl = pithos_endpoints['publicURL']
332         else:
333             purl = self.config.get('file', 'url')\
334                 or self.config.get('pithos', 'url')
335             if not purl:
336                 raise CLIBaseUrlError(service='pithos')
337         return PithosClient(purl, ptoken, self._get_user_id(), container)
338
339     def _store_remote_metafile(self, pclient, remote_path, metadata):
340         return pclient.upload_from_string(
341             remote_path, _validate_image_meta(metadata, return_str=True))
342
343     def _load_params_from_file(self, location):
344         params, properties = dict(), dict()
345         pfile = self['metafile']
346         if pfile:
347             try:
348                 for k, v in _load_image_meta(pfile).items():
349                     key = k.lower().replace('-', '_')
350                     if k == 'properties':
351                         for pk, pv in v.items():
352                             properties[pk.upper().replace('-', '_')] = pv
353                     elif key == 'name':
354                             continue
355                     elif key == 'location':
356                         if location:
357                             continue
358                         location = v
359                     else:
360                         params[key] = v
361             except Exception as e:
362                 raiseCLIError(e, 'Invalid json metadata config file')
363         return params, properties, location
364
365     def _load_params_from_args(self, params, properties):
366         for key in set([
367                 'checksum',
368                 'container_format',
369                 'disk_format',
370                 'owner',
371                 'size',
372                 'is_public']).intersection(self.arguments):
373             params[key] = self[key]
374         for k, v in self['properties'].items():
375             properties[k.upper().replace('-', '_')] = v
376
377     def _validate_location(self, location):
378         if not location:
379             raiseCLIError(
380                 'No image file location provided',
381                 importance=2, details=[
382                     'An image location is needed. Image location format:',
383                     '  pithos://<user-id>/<container>/<path>',
384                     ' an image file at the above location must exist.'
385                     ] + howto_image_file)
386         try:
387             return _validate_image_location(location)
388         except AssertionError as ae:
389             raiseCLIError(
390                 ae, 'Invalid image location format',
391                 importance=1, details=[
392                     'Valid image location format:',
393                     '  pithos://<user-id>/<container>/<img-file-path>'
394                     ] + howto_image_file)
395
396     @errors.generic.all
397     @errors.plankton.connection
398     def _run(self, name, location):
399         (params, properties, location) = self._load_params_from_file(location)
400         uuid, container, img_path = self._validate_location(location)
401         self._load_params_from_args(params, properties)
402         pclient = self._get_pithos_client(container)
403
404         #check if metafile exists
405         meta_path = '%s.meta' % img_path
406         if pclient and not self['metafile_force']:
407             try:
408                 pclient.get_object_info(meta_path)
409                 raiseCLIError('Metadata file %s:%s already exists' % (
410                     container, meta_path))
411             except ClientError as ce:
412                 if ce.status != 404:
413                     raise
414
415         #register the image
416         try:
417             r = self.client.register(name, location, params, properties)
418         except ClientError as ce:
419             if ce.status in (400, ):
420                 raiseCLIError(
421                     ce, 'Nonexistent image file location %s' % location,
422                     details=[
423                         'Make sure the image file exists'] + howto_image_file)
424             raise
425         self._print(r, print_dict)
426
427         #upload the metadata file
428         if pclient:
429             try:
430                 meta_headers = pclient.upload_from_string(
431                     meta_path, dumps(r, indent=2))
432             except TypeError:
433                 print('Failed to dump metafile %s:%s' % (container, meta_path))
434                 return
435             if self['json_output']:
436                 print_json(dict(
437                     metafile_location='%s:%s' % (container, meta_path),
438                     headers=meta_headers))
439             else:
440                 print('Metadata file uploaded as %s:%s (version %s)' % (
441                     container, meta_path, meta_headers['x-object-version']))
442
443     def main(self, name, location=None):
444         super(self.__class__, self)._run()
445         self._run(name, location)
446
447
448 @command(image_cmds)
449 class image_unregister(_init_image, _optional_output_cmd):
450     """Unregister an image (does not delete the image file)"""
451
452     @errors.generic.all
453     @errors.plankton.connection
454     @errors.plankton.id
455     def _run(self, image_id):
456         self._optional_output(self.client.unregister(image_id))
457
458     def main(self, image_id):
459         super(self.__class__, self)._run()
460         self._run(image_id=image_id)
461
462
463 @command(image_cmds)
464 class image_shared(_init_image, _optional_json):
465     """List images shared by a member"""
466
467     @errors.generic.all
468     @errors.plankton.connection
469     def _run(self, member):
470         self._print(self.client.list_shared(member), title=('image_id',))
471
472     def main(self, member):
473         super(self.__class__, self)._run()
474         self._run(member)
475
476
477 @command(image_cmds)
478 class image_members(_init_image):
479     """Manage members. Members of an image are users who can modify it"""
480
481
482 @command(image_cmds)
483 class image_members_list(_init_image, _optional_json):
484     """List members of an image"""
485
486     @errors.generic.all
487     @errors.plankton.connection
488     @errors.plankton.id
489     def _run(self, image_id):
490         self._print(self.client.list_members(image_id), title=('member_id',))
491
492     def main(self, image_id):
493         super(self.__class__, self)._run()
494         self._run(image_id=image_id)
495
496
497 @command(image_cmds)
498 class image_members_add(_init_image, _optional_output_cmd):
499     """Add a member to an image"""
500
501     @errors.generic.all
502     @errors.plankton.connection
503     @errors.plankton.id
504     def _run(self, image_id=None, member=None):
505             self._optional_output(self.client.add_member(image_id, member))
506
507     def main(self, image_id, member):
508         super(self.__class__, self)._run()
509         self._run(image_id=image_id, member=member)
510
511
512 @command(image_cmds)
513 class image_members_delete(_init_image, _optional_output_cmd):
514     """Remove a member from an image"""
515
516     @errors.generic.all
517     @errors.plankton.connection
518     @errors.plankton.id
519     def _run(self, image_id=None, member=None):
520             self._optional_output(self.client.remove_member(image_id, member))
521
522     def main(self, image_id, member):
523         super(self.__class__, self)._run()
524         self._run(image_id=image_id, member=member)
525
526
527 @command(image_cmds)
528 class image_members_set(_init_image, _optional_output_cmd):
529     """Set the members of an image"""
530
531     @errors.generic.all
532     @errors.plankton.connection
533     @errors.plankton.id
534     def _run(self, image_id, members):
535             self._optional_output(self.client.set_members(image_id, members))
536
537     def main(self, image_id, *members):
538         super(self.__class__, self)._run()
539         self._run(image_id=image_id, members=members)
540
541
542 # Compute Image Commands
543
544
545 @command(image_cmds)
546 class image_compute(_init_cyclades):
547     """Cyclades/Compute API image commands"""
548
549
550 @command(image_cmds)
551 class image_compute_list(_init_cyclades, _optional_json):
552     """List images"""
553
554     arguments = dict(
555         detail=FlagArgument('show detailed output', ('-l', '--details')),
556         limit=IntArgument('limit number listed images', ('-n', '--number')),
557         more=FlagArgument(
558             'output results in pages (-n to set items per page, default 10)',
559             '--more'),
560         enum=FlagArgument('Enumerate results', '--enumerate')
561     )
562
563     @errors.generic.all
564     @errors.cyclades.connection
565     def _run(self):
566         images = self.client.list_images(self['detail'])
567         kwargs = dict(with_enumeration=self['enum'])
568         if self['more']:
569             kwargs['page_size'] = self['limit'] or 10
570         elif self['limit']:
571             images = images[:self['limit']]
572         self._print(images, **kwargs)
573
574     def main(self):
575         super(self.__class__, self)._run()
576         self._run()
577
578
579 @command(image_cmds)
580 class image_compute_info(_init_cyclades, _optional_json):
581     """Get detailed information on an image"""
582
583     @errors.generic.all
584     @errors.cyclades.connection
585     @errors.plankton.id
586     def _run(self, image_id):
587         image = self.client.get_image_details(image_id)
588         self._print(image, print_dict)
589
590     def main(self, image_id):
591         super(self.__class__, self)._run()
592         self._run(image_id=image_id)
593
594
595 @command(image_cmds)
596 class image_compute_delete(_init_cyclades, _optional_output_cmd):
597     """Delete an image (WARNING: image file is also removed)"""
598
599     @errors.generic.all
600     @errors.cyclades.connection
601     @errors.plankton.id
602     def _run(self, image_id):
603         self._optional_output(self.client.delete_image(image_id))
604
605     def main(self, image_id):
606         super(self.__class__, self)._run()
607         self._run(image_id=image_id)
608
609
610 @command(image_cmds)
611 class image_compute_properties(_init_cyclades):
612     """Manage properties related to OS installation in an image"""
613
614
615 @command(image_cmds)
616 class image_compute_properties_list(_init_cyclades, _optional_json):
617     """List all image properties"""
618
619     @errors.generic.all
620     @errors.cyclades.connection
621     @errors.plankton.id
622     def _run(self, image_id):
623         self._print(self.client.get_image_metadata(image_id), print_dict)
624
625     def main(self, image_id):
626         super(self.__class__, self)._run()
627         self._run(image_id=image_id)
628
629
630 @command(image_cmds)
631 class image_compute_properties_get(_init_cyclades, _optional_json):
632     """Get an image property"""
633
634     @errors.generic.all
635     @errors.cyclades.connection
636     @errors.plankton.id
637     @errors.plankton.metadata
638     def _run(self, image_id, key):
639         self._print(self.client.get_image_metadata(image_id, key), print_dict)
640
641     def main(self, image_id, key):
642         super(self.__class__, self)._run()
643         self._run(image_id=image_id, key=key)
644
645
646 @command(image_cmds)
647 class image_compute_properties_add(_init_cyclades, _optional_json):
648     """Add a property to an image"""
649
650     @errors.generic.all
651     @errors.cyclades.connection
652     @errors.plankton.id
653     @errors.plankton.metadata
654     def _run(self, image_id, key, val):
655         self._print(
656             self.client.create_image_metadata(image_id, key, val), print_dict)
657
658     def main(self, image_id, key, val):
659         super(self.__class__, self)._run()
660         self._run(image_id=image_id, key=key, val=val)
661
662
663 @command(image_cmds)
664 class image_compute_properties_set(_init_cyclades, _optional_json):
665     """Add / update a set of properties for an image
666     proeprties must be given in the form key=value, e.v.
667     /image compute properties set <image-id> key1=val1 key2=val2
668     """
669
670     @errors.generic.all
671     @errors.cyclades.connection
672     @errors.plankton.id
673     def _run(self, image_id, keyvals):
674         meta = dict()
675         for keyval in keyvals:
676             key, val = keyval.split('=')
677             meta[key] = val
678         self._print(
679             self.client.update_image_metadata(image_id, **meta), print_dict)
680
681     def main(self, image_id, *key_equals_value):
682         super(self.__class__, self)._run()
683         self._run(image_id=image_id, keyvals=key_equals_value)
684
685
686 @command(image_cmds)
687 class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
688     """Delete a property from an image"""
689
690     @errors.generic.all
691     @errors.cyclades.connection
692     @errors.plankton.id
693     @errors.plankton.metadata
694     def _run(self, image_id, key):
695         self._optional_output(self.client.delete_image_metadata(image_id, key))
696
697     def main(self, image_id, key):
698         super(self.__class__, self)._run()
699         self._run(image_id=image_id, key=key)