1 # Copyright 2012-2013 GRNET S.A. All rights reserved.
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
7 # 1. Redistributions of source code must retain the above
8 # copyright notice, this list of conditions and the following
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.
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.
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
34 from json import load, dumps
35 from os.path import abspath
36 from logging import getLogger
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
53 image_cmds = CommandTree(
55 'Cyclades/Plankton API image commands\n'
56 'image compute:\tCyclades/Compute API image commands')
57 _commands = [image_cmds]
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>']
68 about_image_id = ['To see a list of available image ids: /image list']
71 log = getLogger(__name__)
74 class _init_image(_command_init):
78 if getattr(self, 'cloud', None):
79 img_url = self._custom_url('image') or self._custom_url('plankton')
81 token = self._custom_token('image')\
82 or self._custom_token('plankton')\
83 or self.config.get_cloud(self.cloud, 'token')
84 self.client = ImageClient(base_url=img_url, token=token)
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(
92 base_url = plankton_endpoints['publicURL']
93 token = self.auth_base.token
95 raise CLIBaseUrlError(service='plankton')
96 self.client = ImageClient(base_url=base_url, token=token)
102 # Plankton Image Commands
105 def _validate_image_meta(json_dict, return_str=False):
107 :param json_dict" (dict) json-formated, of the form
108 {"key1": "val1", "key2": "val2", ...}
110 :param return_str: (boolean) if true, return a json dump
112 :returns: (dict) if return_str is not True, else return str
114 :raises TypeError, AttributeError: Invalid json format
116 :raises AssertionError: Valid json but invalid image properties dict
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
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
135 def _load_image_meta(filepath):
137 :param filepath: (str) the (relative) path of the metafile
139 :returns: (dict) json_formated
141 :raises TypeError, AttributeError: Invalid json format
143 :raises AssertionError: Valid json but invalid image properties dict
145 with open(abspath(filepath)) as f:
148 return _validate_image_meta(meta_dict)
149 except AssertionError:
150 log.debug('Failed to load properties from file %s' % filepath)
154 def _validate_image_location(location):
156 :param location: (str) pithos://<user-id>/<container>/<image-path>
158 :returns: (<user-id>, <container>, <image-path>)
160 :raises AssertionError: if location is invalid
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
175 class image_list(_init_image, _optional_json):
176 """List images accessible by user"""
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)',
188 name_suff=ValueArgument(
189 'filter by name suffix (case insensitive)',
191 name_like=ValueArgument(
192 'print only if name contains this (case insensitive)',
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'),
199 'order by FIELD ( - to reverse order)',
202 limit=IntArgument('limit number of listed images', ('-n', '--number')),
204 'output results in pages (-n to set items per page, default 10)',
206 enum=FlagArgument('Enumerate results', '--enumerate')
209 def _filtered_by_owner(self, detail, *list_params):
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']:
216 for key in set(img.keys()).difference(MINKEYS):
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())]
229 @errors.cyclades.connection
231 super(self.__class__, self)._run()
239 'status']).intersection(self.arguments):
240 filters[arg] = self[arg]
242 order = self['order']
243 detail = self['detail']
245 images = self._filtered_by_owner(detail, filters, order)
247 images = self.client.list_public(detail, filters, order)
249 images = self._filtered_by_name(images)
250 kwargs = dict(with_enumeration=self['enum'])
252 kwargs['page_size'] = self['limit'] or 10
254 images = images[:self['limit']]
255 self._print(images, **kwargs)
258 super(self.__class__, self)._run()
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.)
272 @errors.plankton.connection
274 def _run(self, image_id):
275 self._print([self.client.get_meta(image_id)])
277 def main(self, image_id):
278 super(self.__class__, self)._run()
279 self._run(image_id=image_id)
283 class image_register(_init_image, _optional_json):
284 """(Re)Register an image"""
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: {...}"}',
302 metafile_force=FlagArgument(
303 'Store remote metadata object, even if it already exists',
305 no_metafile_upload=FlagArgument(
306 'Do not store metadata in remote meta file',
307 ('--no-metafile-upload')),
308 container=ValueArgument(
309 'Pithos+ container containing the image file',
310 ('-C', '--container')),
311 uuid=ValueArgument('Custom user uuid', '--uuid')
314 def _get_user_id(self):
315 atoken = self.client.token
316 if getattr(self, 'auth_base', False):
317 return self.auth_base.term('id', atoken)
319 astakos_url = self.config.get('user', 'url')\
320 or self.config.get('astakos', 'url')
322 raise CLIBaseUrlError(service='astakos')
323 user = AstakosClient(astakos_url, atoken)
324 return user.term('id')
326 def _get_pithos_client(self, container):
327 if self['no_metafile_upload']:
329 ptoken = self.client.token
330 if getattr(self, 'auth_base', False):
331 pithos_endpoints = self.auth_base.get_service_endpoints(
333 purl = pithos_endpoints['publicURL']
335 purl = self.config.get_cloud('pithos', 'url')
337 raise CLIBaseUrlError(service='pithos')
338 return PithosClient(purl, ptoken, self._get_user_id(), container)
340 def _store_remote_metafile(self, pclient, remote_path, metadata):
341 return pclient.upload_from_string(
342 remote_path, _validate_image_meta(metadata, return_str=True))
344 def _load_params_from_file(self, location):
345 params, properties = dict(), dict()
346 pfile = self['metafile']
349 for k, v in _load_image_meta(pfile).items():
350 key = k.lower().replace('-', '_')
351 if k == 'properties':
352 for pk, pv in v.items():
353 properties[pk.upper().replace('-', '_')] = pv
356 elif key == 'location':
362 except Exception as e:
363 raiseCLIError(e, 'Invalid json metadata config file')
364 return params, properties, location
366 def _load_params_from_args(self, params, properties):
373 'is_public']).intersection(self.arguments):
374 params[key] = self[key]
375 for k, v in self['properties'].items():
376 properties[k.upper().replace('-', '_')] = v
378 def _validate_location(self, location):
381 'No image file location provided',
382 importance=2, details=[
383 'An image location is needed. Image location format:',
384 ' pithos://<user-id>/<container>/<path>',
385 ' where an image file at the above location must exist.'
386 ] + howto_image_file)
388 return _validate_image_location(location)
389 except AssertionError as ae:
391 ae, 'Invalid image location format',
392 importance=1, details=[
393 'Valid image location format:',
394 ' pithos://<user-id>/<container>/<img-file-path>'
395 ] + howto_image_file)
397 def _mine_location(self, container_path):
398 uuid = self['uuid'] or self._get_user_id()
399 if self['container']:
400 return uuid, self['container'], container_path
401 container, sep, path = container_path.partition(':')
402 if not (bool(container) and bool(path)):
404 'Incorrect container-path format', importance=1, details=[
405 'Use : to seperate container form path',
406 ' <container>:<image-path>',
408 'Use -C to specifiy a container',
409 ' -C <container> <image-path>'] + howto_image_file)
411 return uuid, container, path
414 @errors.plankton.connection
415 def _run(self, name, uuid, container, img_path):
416 location = 'pithos://%s/%s/%s' % (uuid, container, img_path)
417 (params, properties, new_loc) = self._load_params_from_file(location)
418 if location != new_loc:
419 uuid, container, img_path = self._validate_location(new_loc)
420 self._load_params_from_args(params, properties)
421 pclient = self._get_pithos_client(container)
423 #check if metafile exists
424 meta_path = '%s.meta' % img_path
425 if pclient and not self['metafile_force']:
427 pclient.get_object_info(meta_path)
429 'Metadata file %s:%s already exists, abort' % (
430 container, meta_path),
431 details=['Registration ABORTED', 'Try -f to overwrite'])
432 except ClientError as ce:
438 r = self.client.register(name, location, params, properties)
439 except ClientError as ce:
440 if ce.status in (400, ):
442 ce, 'Nonexistent image file location %s' % location,
444 'Make sure the image file exists'] + howto_image_file)
446 self._print(r, print_dict)
448 #upload the metadata file
451 meta_headers = pclient.upload_from_string(
452 meta_path, dumps(r, indent=2))
454 print('Failed to dump metafile %s:%s' % (container, meta_path))
456 if self['json_output']:
458 metafile_location='%s:%s' % (container, meta_path),
459 headers=meta_headers))
461 print('Metadata file uploaded as %s:%s (version %s)' % (
462 container, meta_path, meta_headers['x-object-version']))
464 def main(self, name, container___image_path):
465 super(self.__class__, self)._run()
466 self._run(name, *self._mine_location(container___image_path))
470 class image_unregister(_init_image, _optional_output_cmd):
471 """Unregister an image (does not delete the image file)"""
474 @errors.plankton.connection
476 def _run(self, image_id):
477 self._optional_output(self.client.unregister(image_id))
479 def main(self, image_id):
480 super(self.__class__, self)._run()
481 self._run(image_id=image_id)
485 class image_shared(_init_image, _optional_json):
486 """List images shared by a member"""
489 @errors.plankton.connection
490 def _run(self, member):
491 self._print(self.client.list_shared(member), title=('image_id',))
493 def main(self, member):
494 super(self.__class__, self)._run()
499 class image_members(_init_image):
500 """Manage members. Members of an image are users who can modify it"""
504 class image_members_list(_init_image, _optional_json):
505 """List members of an image"""
508 @errors.plankton.connection
510 def _run(self, image_id):
511 self._print(self.client.list_members(image_id), title=('member_id',))
513 def main(self, image_id):
514 super(self.__class__, self)._run()
515 self._run(image_id=image_id)
519 class image_members_add(_init_image, _optional_output_cmd):
520 """Add a member to an image"""
523 @errors.plankton.connection
525 def _run(self, image_id=None, member=None):
526 self._optional_output(self.client.add_member(image_id, member))
528 def main(self, image_id, member):
529 super(self.__class__, self)._run()
530 self._run(image_id=image_id, member=member)
534 class image_members_delete(_init_image, _optional_output_cmd):
535 """Remove a member from an image"""
538 @errors.plankton.connection
540 def _run(self, image_id=None, member=None):
541 self._optional_output(self.client.remove_member(image_id, member))
543 def main(self, image_id, member):
544 super(self.__class__, self)._run()
545 self._run(image_id=image_id, member=member)
549 class image_members_set(_init_image, _optional_output_cmd):
550 """Set the members of an image"""
553 @errors.plankton.connection
555 def _run(self, image_id, members):
556 self._optional_output(self.client.set_members(image_id, members))
558 def main(self, image_id, *members):
559 super(self.__class__, self)._run()
560 self._run(image_id=image_id, members=members)
563 # Compute Image Commands
567 class image_compute(_init_cyclades):
568 """Cyclades/Compute API image commands"""
572 class image_compute_list(_init_cyclades, _optional_json):
576 detail=FlagArgument('show detailed output', ('-l', '--details')),
577 limit=IntArgument('limit number listed images', ('-n', '--number')),
579 'output results in pages (-n to set items per page, default 10)',
581 enum=FlagArgument('Enumerate results', '--enumerate')
585 @errors.cyclades.connection
587 images = self.client.list_images(self['detail'])
588 kwargs = dict(with_enumeration=self['enum'])
590 kwargs['page_size'] = self['limit'] or 10
592 images = images[:self['limit']]
593 self._print(images, **kwargs)
596 super(self.__class__, self)._run()
601 class image_compute_info(_init_cyclades, _optional_json):
602 """Get detailed information on an image"""
605 @errors.cyclades.connection
607 def _run(self, image_id):
608 image = self.client.get_image_details(image_id)
609 self._print(image, print_dict)
611 def main(self, image_id):
612 super(self.__class__, self)._run()
613 self._run(image_id=image_id)
617 class image_compute_delete(_init_cyclades, _optional_output_cmd):
618 """Delete an image (WARNING: image file is also removed)"""
621 @errors.cyclades.connection
623 def _run(self, image_id):
624 self._optional_output(self.client.delete_image(image_id))
626 def main(self, image_id):
627 super(self.__class__, self)._run()
628 self._run(image_id=image_id)
632 class image_compute_properties(_init_cyclades):
633 """Manage properties related to OS installation in an image"""
637 class image_compute_properties_list(_init_cyclades, _optional_json):
638 """List all image properties"""
641 @errors.cyclades.connection
643 def _run(self, image_id):
644 self._print(self.client.get_image_metadata(image_id), print_dict)
646 def main(self, image_id):
647 super(self.__class__, self)._run()
648 self._run(image_id=image_id)
652 class image_compute_properties_get(_init_cyclades, _optional_json):
653 """Get an image property"""
656 @errors.cyclades.connection
658 @errors.plankton.metadata
659 def _run(self, image_id, key):
660 self._print(self.client.get_image_metadata(image_id, key), print_dict)
662 def main(self, image_id, key):
663 super(self.__class__, self)._run()
664 self._run(image_id=image_id, key=key)
668 class image_compute_properties_add(_init_cyclades, _optional_json):
669 """Add a property to an image"""
672 @errors.cyclades.connection
674 @errors.plankton.metadata
675 def _run(self, image_id, key, val):
677 self.client.create_image_metadata(image_id, key, val), print_dict)
679 def main(self, image_id, key, val):
680 super(self.__class__, self)._run()
681 self._run(image_id=image_id, key=key, val=val)
685 class image_compute_properties_set(_init_cyclades, _optional_json):
686 """Add / update a set of properties for an image
687 proeprties must be given in the form key=value, e.v.
688 /image compute properties set <image-id> key1=val1 key2=val2
692 @errors.cyclades.connection
694 def _run(self, image_id, keyvals):
696 for keyval in keyvals:
697 key, val = keyval.split('=')
700 self.client.update_image_metadata(image_id, **meta), print_dict)
702 def main(self, image_id, *key_equals_value):
703 super(self.__class__, self)._run()
704 self._run(image_id=image_id, keyvals=key_equals_value)
708 class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
709 """Delete a property from an image"""
712 @errors.cyclades.connection
714 @errors.plankton.metadata
715 def _run(self, image_id, key):
716 self._optional_output(self.client.delete_image_metadata(image_id, key))
718 def main(self, image_id, key):
719 super(self.__class__, self)._run()
720 self._run(image_id=image_id, key=key)