1 # Copyright 2012 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_remote(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>/<img-file-path>
158 :returns: (<user-id>, <container>, <img-file-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')),
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)
316 astakos_url = self.config.get('user', 'url')\
317 or self.config.get('astakos', 'url')
319 raise CLIBaseUrlError(service='astakos')
320 user = AstakosClient(astakos_url, atoken)
321 return user.term('id')
323 def _get_pithos_client(self, container):
324 if self['no_metafile_upload']:
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']
333 purl = self.config.get('file', 'url')\
334 or self.config.get('pithos', 'url')
336 raise CLIBaseUrlError(service='pithos')
337 return PithosClient(purl, ptoken, self._get_user_id(), container)
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))
343 def _load_params_from_file(self, location):
344 params, properties = dict(), dict()
345 pfile = self['metafile']
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
355 elif key == 'location':
361 except Exception as e:
362 raiseCLIError(e, 'Invalid json metadata config file')
363 return params, properties, location
365 def _load_params_from_args(self, params, properties):
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
377 def _validate_location(self, location):
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)
387 return _validate_image_location(location)
388 except AssertionError as ae:
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)
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)
404 #check if metafile exists
405 meta_path = '%s.meta' % img_path
406 if pclient and not self['metafile_force']:
408 pclient.get_object_info(meta_path)
409 raiseCLIError('Metadata file %s:%s already exists' % (
410 container, meta_path))
411 except ClientError as ce:
417 r = self.client.register(name, location, params, properties)
418 except ClientError as ce:
419 if ce.status in (400, ):
421 ce, 'Nonexistent image file location %s' % location,
423 'Make sure the image file exists'] + howto_image_file)
425 self._print(r, print_dict)
427 #upload the metadata file
430 meta_headers = pclient.upload_from_string(
431 meta_path, dumps(r, indent=2))
433 print('Failed to dump metafile %s:%s' % (container, meta_path))
435 if self['json_output']:
437 metafile_location='%s:%s' % (container, meta_path),
438 headers=meta_headers))
440 print('Metadata file uploaded as %s:%s (version %s)' % (
441 container, meta_path, meta_headers['x-object-version']))
443 def main(self, name, location=None):
444 super(self.__class__, self)._run()
445 self._run(name, location)
449 class image_unregister(_init_image, _optional_output_cmd):
450 """Unregister an image (does not delete the image file)"""
453 @errors.plankton.connection
455 def _run(self, image_id):
456 self._optional_output(self.client.unregister(image_id))
458 def main(self, image_id):
459 super(self.__class__, self)._run()
460 self._run(image_id=image_id)
464 class image_shared(_init_image, _optional_json):
465 """List images shared by a member"""
468 @errors.plankton.connection
469 def _run(self, member):
470 self._print(self.client.list_shared(member), title=('image_id',))
472 def main(self, member):
473 super(self.__class__, self)._run()
478 class image_members(_init_image):
479 """Manage members. Members of an image are users who can modify it"""
483 class image_members_list(_init_image, _optional_json):
484 """List members of an image"""
487 @errors.plankton.connection
489 def _run(self, image_id):
490 self._print(self.client.list_members(image_id), title=('member_id',))
492 def main(self, image_id):
493 super(self.__class__, self)._run()
494 self._run(image_id=image_id)
498 class image_members_add(_init_image, _optional_output_cmd):
499 """Add a member to an image"""
502 @errors.plankton.connection
504 def _run(self, image_id=None, member=None):
505 self._optional_output(self.client.add_member(image_id, member))
507 def main(self, image_id, member):
508 super(self.__class__, self)._run()
509 self._run(image_id=image_id, member=member)
513 class image_members_delete(_init_image, _optional_output_cmd):
514 """Remove a member from an image"""
517 @errors.plankton.connection
519 def _run(self, image_id=None, member=None):
520 self._optional_output(self.client.remove_member(image_id, member))
522 def main(self, image_id, member):
523 super(self.__class__, self)._run()
524 self._run(image_id=image_id, member=member)
528 class image_members_set(_init_image, _optional_output_cmd):
529 """Set the members of an image"""
532 @errors.plankton.connection
534 def _run(self, image_id, members):
535 self._optional_output(self.client.set_members(image_id, members))
537 def main(self, image_id, *members):
538 super(self.__class__, self)._run()
539 self._run(image_id=image_id, members=members)
542 # Compute Image Commands
546 class image_compute(_init_cyclades):
547 """Cyclades/Compute API image commands"""
551 class image_compute_list(_init_cyclades, _optional_json):
555 detail=FlagArgument('show detailed output', ('-l', '--details')),
556 limit=IntArgument('limit number listed images', ('-n', '--number')),
558 'output results in pages (-n to set items per page, default 10)',
560 enum=FlagArgument('Enumerate results', '--enumerate')
564 @errors.cyclades.connection
566 images = self.client.list_images(self['detail'])
567 kwargs = dict(with_enumeration=self['enum'])
569 kwargs['page_size'] = self['limit'] or 10
571 images = images[:self['limit']]
572 self._print(images, **kwargs)
575 super(self.__class__, self)._run()
580 class image_compute_info(_init_cyclades, _optional_json):
581 """Get detailed information on an image"""
584 @errors.cyclades.connection
586 def _run(self, image_id):
587 image = self.client.get_image_details(image_id)
588 self._print(image, print_dict)
590 def main(self, image_id):
591 super(self.__class__, self)._run()
592 self._run(image_id=image_id)
596 class image_compute_delete(_init_cyclades, _optional_output_cmd):
597 """Delete an image (WARNING: image file is also removed)"""
600 @errors.cyclades.connection
602 def _run(self, image_id):
603 self._optional_output(self.client.delete_image(image_id))
605 def main(self, image_id):
606 super(self.__class__, self)._run()
607 self._run(image_id=image_id)
611 class image_compute_properties(_init_cyclades):
612 """Manage properties related to OS installation in an image"""
616 class image_compute_properties_list(_init_cyclades, _optional_json):
617 """List all image properties"""
620 @errors.cyclades.connection
622 def _run(self, image_id):
623 self._print(self.client.get_image_metadata(image_id), print_dict)
625 def main(self, image_id):
626 super(self.__class__, self)._run()
627 self._run(image_id=image_id)
631 class image_compute_properties_get(_init_cyclades, _optional_json):
632 """Get an image property"""
635 @errors.cyclades.connection
637 @errors.plankton.metadata
638 def _run(self, image_id, key):
639 self._print(self.client.get_image_metadata(image_id, key), print_dict)
641 def main(self, image_id, key):
642 super(self.__class__, self)._run()
643 self._run(image_id=image_id, key=key)
647 class image_compute_properties_add(_init_cyclades, _optional_json):
648 """Add a property to an image"""
651 @errors.cyclades.connection
653 @errors.plankton.metadata
654 def _run(self, image_id, key, val):
656 self.client.create_image_metadata(image_id, key, val), print_dict)
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)
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
671 @errors.cyclades.connection
673 def _run(self, image_id, keyvals):
675 for keyval in keyvals:
676 key, val = keyval.split('=')
679 self.client.update_image_metadata(image_id, **meta), print_dict)
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)
687 class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
688 """Delete a property from an image"""
691 @errors.cyclades.connection
693 @errors.plankton.metadata
694 def _run(self, image_id, key):
695 self._optional_output(self.client.delete_image_metadata(image_id, key))
697 def main(self, image_id, key):
698 super(self.__class__, self)._run()
699 self._run(image_id=image_id, key=key)