100665607447dc0f07ce78ab07b9decc991b62d0
[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
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('global', 'token')
79
80         if getattr(self, 'auth_base', False):
81             plankton_endpoints = self.auth_base.get_service_endpoints(
82                 self.config.get('plankton', 'type'),
83                 self.config.get('plankton', 'version'))
84             base_url = plankton_endpoints['publicURL']
85         else:
86             base_url = self.config.get('plankton', 'url')
87         if not base_url:
88             raise CLIBaseUrlError(service='plankton')
89
90         self.client = ImageClient(base_url=base_url, token=token)
91         self._set_log_params()
92         self._update_max_threads()
93
94     def main(self):
95         self._run()
96
97
98 # Plankton Image Commands
99
100
101 def _validate_image_meta(json_dict, return_str=False):
102     """
103     :param json_dict" (dict) json-formated, of the form
104         {"key1": "val1", "key2": "val2", ...}
105
106     :param return_str: (boolean) if true, return a json dump
107
108     :returns: (dict) if return_str is not True, else return str
109
110     :raises TypeError, AttributeError: Invalid json format
111
112     :raises AssertionError: Valid json but invalid image properties dict
113     """
114     json_str = dumps(json_dict, indent=2)
115     for k, v in json_dict.items():
116         if k.lower() == 'properties':
117             for pk, pv in v.items():
118                 prop_ok = not (isinstance(pv, dict) or isinstance(pv, list))
119                 assert prop_ok, 'Invalid property value for key %s' % pk
120                 key_ok = not (' ' in k or '-' in k)
121                 assert key_ok, 'Invalid property key %s' % k
122             continue
123         meta_ok = not (isinstance(v, dict) or isinstance(v, list))
124         assert meta_ok, 'Invalid value for meta key %s' % k
125         meta_ok = ' ' not in k
126         assert meta_ok, 'Invalid meta key [%s]' % k
127         json_dict[k] = '%s' % v
128     return json_str if return_str else json_dict
129
130
131 def _load_image_meta(filepath):
132     """
133     :param filepath: (str) the (relative) path of the metafile
134
135     :returns: (dict) json_formated
136
137     :raises TypeError, AttributeError: Invalid json format
138
139     :raises AssertionError: Valid json but invalid image properties dict
140     """
141     with open(abspath(filepath)) as f:
142         meta_dict = load(f)
143         try:
144             return _validate_image_meta(meta_dict)
145         except AssertionError:
146             log.debug('Failed to load properties from file %s' % filepath)
147             raise
148
149
150 def _validate_image_location(location):
151     """
152     :param location: (str) pithos://<uuid>/<container>/<img-file-path>
153
154     :returns: (<uuid>, <container>, <img-file-path>)
155
156     :raises AssertionError: if location is invalid
157     """
158     prefix = 'pithos://'
159     msg = 'Invalid prefix for location %s , try: %s' % (location, prefix)
160     assert location.startswith(prefix), msg
161     service, sep, rest = location.partition('://')
162     assert sep and rest, 'Location %s is missing uuid' % location
163     uuid, sep, rest = rest.partition('/')
164     assert sep and rest, 'Location %s is missing container' % location
165     container, sep, img_path = rest.partition('/')
166     assert sep and img_path, 'Location %s is missing image path' % location
167     return uuid, container, img_path
168
169
170 @command(image_cmds)
171 class image_list(_init_image, _optional_json):
172     """List images accessible by user"""
173
174     arguments = dict(
175         detail=FlagArgument('show detailed output', ('-l', '--details')),
176         container_format=ValueArgument(
177             'filter by container format',
178             '--container-format'),
179         disk_format=ValueArgument('filter by disk format', '--disk-format'),
180         name=ValueArgument('filter by name', '--name'),
181         name_pref=ValueArgument(
182             'filter by name prefix (case insensitive)',
183             '--name-prefix'),
184         name_suff=ValueArgument(
185             'filter by name suffix (case insensitive)',
186             '--name-suffix'),
187         name_like=ValueArgument(
188             'print only if name contains this (case insensitive)',
189             '--name-like'),
190         size_min=IntArgument('filter by minimum size', '--size-min'),
191         size_max=IntArgument('filter by maximum size', '--size-max'),
192         status=ValueArgument('filter by status', '--status'),
193         owner=ValueArgument('filter by owner', '--owner'),
194         order=ValueArgument(
195             'order by FIELD ( - to reverse order)',
196             '--order',
197             default=''),
198         limit=IntArgument('limit number of listed images', ('-n', '--number')),
199         more=FlagArgument(
200             'output results in pages (-n to set items per page, default 10)',
201             '--more'),
202         enum=FlagArgument('Enumerate results', '--enumerate')
203     )
204
205     def _filtered_by_owner(self, detail, *list_params):
206         images = []
207         MINKEYS = set([
208             'id', 'size', 'status', 'disk_format', 'container_format', 'name'])
209         for img in self.client.list_public(True, *list_params):
210             if img['owner'] == self['owner']:
211                 if not detail:
212                     for key in set(img.keys()).difference(MINKEYS):
213                         img.pop(key)
214                 images.append(img)
215         return images
216
217     def _filtered_by_name(self, images):
218         np, ns, nl = self['name_pref'], self['name_suff'], self['name_like']
219         return [img for img in images if (
220             (not np) or img['name'].lower().startswith(np.lower())) and (
221             (not ns) or img['name'].lower().endswith(ns.lower())) and (
222             (not nl) or nl.lower() in img['name'].lower())]
223
224     @errors.generic.all
225     @errors.cyclades.connection
226     def _run(self):
227         super(self.__class__, self)._run()
228         filters = {}
229         for arg in set([
230                 'container_format',
231                 'disk_format',
232                 'name',
233                 'size_min',
234                 'size_max',
235                 'status']).intersection(self.arguments):
236             filters[arg] = self[arg]
237
238         order = self['order']
239         detail = self['detail']
240         if self['owner']:
241             images = self._filtered_by_owner(detail, filters, order)
242         else:
243             images = self.client.list_public(detail, filters, order)
244
245         images = self._filtered_by_name(images)
246         kwargs = dict(with_enumeration=self['enum'])
247         if self['more']:
248             kwargs['page_size'] = self['limit'] or 10
249         elif self['limit']:
250             images = images[:self['limit']]
251         self._print(images, **kwargs)
252
253     def main(self):
254         super(self.__class__, self)._run()
255         self._run()
256
257
258 @command(image_cmds)
259 class image_meta(_init_image, _optional_json):
260     """Get image metadata
261     Image metadata include:
262     - image file information (location, size, etc.)
263     - image information (id, name, etc.)
264     - image os properties (os, fs, etc.)
265     """
266
267     @errors.generic.all
268     @errors.plankton.connection
269     @errors.plankton.id
270     def _run(self, image_id):
271         self._print([self.client.get_meta(image_id)])
272
273     def main(self, image_id):
274         super(self.__class__, self)._run()
275         self._run(image_id=image_id)
276
277
278 @command(image_cmds)
279 class image_register(_init_image, _optional_json):
280     """(Re)Register an image"""
281
282     arguments = dict(
283         checksum=ValueArgument('set image checksum', '--checksum'),
284         container_format=ValueArgument(
285             'set container format',
286             '--container-format'),
287         disk_format=ValueArgument('set disk format', '--disk-format'),
288         owner=ValueArgument('set image owner (admin only)', '--owner'),
289         properties=KeyValueArgument(
290             'add property in key=value form (can be repeated)',
291             ('-p', '--property')),
292         is_public=FlagArgument('mark image as public', '--public'),
293         size=IntArgument('set image size', '--size'),
294         metafile=ValueArgument(
295             'Load metadata from a json-formated file <img-file>.meta :'
296             '{"key1": "val1", "key2": "val2", ..., "properties: {...}"}',
297             ('--metafile')),
298         metafile_force=FlagArgument(
299             'Store remote metadata object, even if it already exists',
300             ('-f', '--force')),
301         no_metafile_upload=FlagArgument(
302             'Do not store metadata in remote meta file',
303             ('--no-metafile-upload')),
304
305     )
306
307     def _get_uuid(self):
308         atoken = self.client.token
309         #user = AstakosClient(self.config.get('user', 'url'), atoken)
310         #return user.term('uuid')
311         self.auth_base.term('uuid', atoken)
312
313     def _get_pithos_client(self, container):
314         if self['no_metafile_upload']:
315             return None
316         pithos_endpoints = self.auth_base.get_service_endpoints(
317             self.config.get('pithos', 'type'),
318             self.config.get('pithos', 'version'))
319         purl = pithos_endpoints['publicURL']
320         ptoken = self.client.token
321         return PithosClient(purl, ptoken, self._get_uuid(), container)
322
323     def _store_remote_metafile(self, pclient, remote_path, metadata):
324         return pclient.upload_from_string(
325             remote_path, _validate_image_meta(metadata, return_str=True))
326
327     def _load_params_from_file(self, location):
328         params, properties = dict(), dict()
329         pfile = self['metafile']
330         if pfile:
331             try:
332                 for k, v in _load_image_meta(pfile).items():
333                     key = k.lower().replace('-', '_')
334                     if k == 'properties':
335                         for pk, pv in v.items():
336                             properties[pk.upper().replace('-', '_')] = pv
337                     elif key == 'name':
338                             continue
339                     elif key == 'location':
340                         if location:
341                             continue
342                         location = v
343                     else:
344                         params[key] = v
345             except Exception as e:
346                 raiseCLIError(e, 'Invalid json metadata config file')
347         return params, properties, location
348
349     def _load_params_from_args(self, params, properties):
350         for key in set([
351                 'checksum',
352                 'container_format',
353                 'disk_format',
354                 'owner',
355                 'size',
356                 'is_public']).intersection(self.arguments):
357             params[key] = self[key]
358         for k, v in self['properties'].items():
359             properties[k.upper().replace('-', '_')] = v
360
361     def _validate_location(self, location):
362         if not location:
363             raiseCLIError(
364                 'No image file location provided',
365                 importance=2, details=[
366                     'An image location is needed. Image location format:',
367                     '  pithos://<uuid>/<container>/<path>',
368                     ' an image file at the above location must exist.'
369                     ] + howto_image_file)
370         try:
371             return _validate_image_location(location)
372         except AssertionError as ae:
373             raiseCLIError(
374                 ae, 'Invalid image location format',
375                 importance=1, details=[
376                     'Valid image location format:',
377                     '  pithos://<uuid>/<container>/<img-file-path>'
378                     ] + howto_image_file)
379
380     @errors.generic.all
381     @errors.plankton.connection
382     def _run(self, name, location):
383         (params, properties, location) = self._load_params_from_file(location)
384         uuid, container, img_path = self._validate_location(location)
385         self._load_params_from_args(params, properties)
386         pclient = self._get_pithos_client(container)
387
388         #check if metafile exists
389         meta_path = '%s.meta' % img_path
390         if pclient and not self['metafile_force']:
391             try:
392                 pclient.get_object_info(meta_path)
393                 raiseCLIError('Metadata file %s:%s already exists' % (
394                     container, meta_path))
395             except ClientError as ce:
396                 if ce.status != 404:
397                     raise
398
399         #register the image
400         try:
401             r = self.client.register(name, location, params, properties)
402         except ClientError as ce:
403             if ce.status in (400, ):
404                 raiseCLIError(
405                     ce, 'Nonexistent image file location %s' % location,
406                     details=[
407                         'Make sure the image file exists'] + howto_image_file)
408             raise
409         self._print(r, print_dict)
410
411         #upload the metadata file
412         if pclient:
413             try:
414                 meta_headers = pclient.upload_from_string(
415                     meta_path, dumps(r, indent=2))
416             except TypeError:
417                 print('Failed to dump metafile %s:%s' % (container, meta_path))
418                 return
419             if self['json_output']:
420                 print_json(dict(
421                     metafile_location='%s:%s' % (container, meta_path),
422                     headers=meta_headers))
423             else:
424                 print('Metadata file uploaded as %s:%s (version %s)' % (
425                     container, meta_path, meta_headers['x-object-version']))
426
427     def main(self, name, location=None):
428         super(self.__class__, self)._run()
429         self._run(name, location)
430
431
432 @command(image_cmds)
433 class image_unregister(_init_image, _optional_output_cmd):
434     """Unregister an image (does not delete the image file)"""
435
436     @errors.generic.all
437     @errors.plankton.connection
438     @errors.plankton.id
439     def _run(self, image_id):
440         self._optional_output(self.client.unregister(image_id))
441
442     def main(self, image_id):
443         super(self.__class__, self)._run()
444         self._run(image_id=image_id)
445
446
447 @command(image_cmds)
448 class image_shared(_init_image, _optional_json):
449     """List images shared by a member"""
450
451     @errors.generic.all
452     @errors.plankton.connection
453     def _run(self, member):
454         self._print(self.client.list_shared(member), title=('image_id',))
455
456     def main(self, member):
457         super(self.__class__, self)._run()
458         self._run(member)
459
460
461 @command(image_cmds)
462 class image_members(_init_image):
463     """Manage members. Members of an image are users who can modify it"""
464
465
466 @command(image_cmds)
467 class image_members_list(_init_image, _optional_json):
468     """List members of an image"""
469
470     @errors.generic.all
471     @errors.plankton.connection
472     @errors.plankton.id
473     def _run(self, image_id):
474         self._print(self.client.list_members(image_id), title=('member_id',))
475
476     def main(self, image_id):
477         super(self.__class__, self)._run()
478         self._run(image_id=image_id)
479
480
481 @command(image_cmds)
482 class image_members_add(_init_image, _optional_output_cmd):
483     """Add a member to an image"""
484
485     @errors.generic.all
486     @errors.plankton.connection
487     @errors.plankton.id
488     def _run(self, image_id=None, member=None):
489             self._optional_output(self.client.add_member(image_id, member))
490
491     def main(self, image_id, member):
492         super(self.__class__, self)._run()
493         self._run(image_id=image_id, member=member)
494
495
496 @command(image_cmds)
497 class image_members_delete(_init_image, _optional_output_cmd):
498     """Remove a member from an image"""
499
500     @errors.generic.all
501     @errors.plankton.connection
502     @errors.plankton.id
503     def _run(self, image_id=None, member=None):
504             self._optional_output(self.client.remove_member(image_id, member))
505
506     def main(self, image_id, member):
507         super(self.__class__, self)._run()
508         self._run(image_id=image_id, member=member)
509
510
511 @command(image_cmds)
512 class image_members_set(_init_image, _optional_output_cmd):
513     """Set the members of an image"""
514
515     @errors.generic.all
516     @errors.plankton.connection
517     @errors.plankton.id
518     def _run(self, image_id, members):
519             self._optional_output(self.client.set_members(image_id, members))
520
521     def main(self, image_id, *members):
522         super(self.__class__, self)._run()
523         self._run(image_id=image_id, members=members)
524
525
526 # Compute Image Commands
527
528
529 @command(image_cmds)
530 class image_compute(_init_cyclades):
531     """Cyclades/Compute API image commands"""
532
533
534 @command(image_cmds)
535 class image_compute_list(_init_cyclades, _optional_json):
536     """List images"""
537
538     arguments = dict(
539         detail=FlagArgument('show detailed output', ('-l', '--details')),
540         limit=IntArgument('limit number listed images', ('-n', '--number')),
541         more=FlagArgument(
542             'output results in pages (-n to set items per page, default 10)',
543             '--more'),
544         enum=FlagArgument('Enumerate results', '--enumerate')
545     )
546
547     @errors.generic.all
548     @errors.cyclades.connection
549     def _run(self):
550         images = self.client.list_images(self['detail'])
551         kwargs = dict(with_enumeration=self['enum'])
552         if self['more']:
553             kwargs['page_size'] = self['limit'] or 10
554         elif self['limit']:
555             images = images[:self['limit']]
556         self._print(images, **kwargs)
557
558     def main(self):
559         super(self.__class__, self)._run()
560         self._run()
561
562
563 @command(image_cmds)
564 class image_compute_info(_init_cyclades, _optional_json):
565     """Get detailed information on an image"""
566
567     @errors.generic.all
568     @errors.cyclades.connection
569     @errors.plankton.id
570     def _run(self, image_id):
571         image = self.client.get_image_details(image_id)
572         self._print(image, print_dict)
573
574     def main(self, image_id):
575         super(self.__class__, self)._run()
576         self._run(image_id=image_id)
577
578
579 @command(image_cmds)
580 class image_compute_delete(_init_cyclades, _optional_output_cmd):
581     """Delete an image (WARNING: image file is also removed)"""
582
583     @errors.generic.all
584     @errors.cyclades.connection
585     @errors.plankton.id
586     def _run(self, image_id):
587         self._optional_output(self.client.delete_image(image_id))
588
589     def main(self, image_id):
590         super(self.__class__, self)._run()
591         self._run(image_id=image_id)
592
593
594 @command(image_cmds)
595 class image_compute_properties(_init_cyclades):
596     """Manage properties related to OS installation in an image"""
597
598
599 @command(image_cmds)
600 class image_compute_properties_list(_init_cyclades, _optional_json):
601     """List all image properties"""
602
603     @errors.generic.all
604     @errors.cyclades.connection
605     @errors.plankton.id
606     def _run(self, image_id):
607         self._print(self.client.get_image_metadata(image_id), print_dict)
608
609     def main(self, image_id):
610         super(self.__class__, self)._run()
611         self._run(image_id=image_id)
612
613
614 @command(image_cmds)
615 class image_compute_properties_get(_init_cyclades, _optional_json):
616     """Get an image property"""
617
618     @errors.generic.all
619     @errors.cyclades.connection
620     @errors.plankton.id
621     @errors.plankton.metadata
622     def _run(self, image_id, key):
623         self._print(self.client.get_image_metadata(image_id, key), print_dict)
624
625     def main(self, image_id, key):
626         super(self.__class__, self)._run()
627         self._run(image_id=image_id, key=key)
628
629
630 @command(image_cmds)
631 class image_compute_properties_add(_init_cyclades, _optional_json):
632     """Add a property to an image"""
633
634     @errors.generic.all
635     @errors.cyclades.connection
636     @errors.plankton.id
637     @errors.plankton.metadata
638     def _run(self, image_id, key, val):
639         self._print(
640             self.client.create_image_metadata(image_id, key, val), print_dict)
641
642     def main(self, image_id, key, val):
643         super(self.__class__, self)._run()
644         self._run(image_id=image_id, key=key, val=val)
645
646
647 @command(image_cmds)
648 class image_compute_properties_set(_init_cyclades, _optional_json):
649     """Add / update a set of properties for an image
650     proeprties must be given in the form key=value, e.v.
651     /image compute properties set <image-id> key1=val1 key2=val2
652     """
653
654     @errors.generic.all
655     @errors.cyclades.connection
656     @errors.plankton.id
657     def _run(self, image_id, keyvals):
658         meta = dict()
659         for keyval in keyvals:
660             key, val = keyval.split('=')
661             meta[key] = val
662         self._print(
663             self.client.update_image_metadata(image_id, **meta), print_dict)
664
665     def main(self, image_id, *key_equals_value):
666         super(self.__class__, self)._run()
667         self._run(image_id=image_id, keyvals=key_equals_value)
668
669
670 @command(image_cmds)
671 class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
672     """Delete a property from an image"""
673
674     @errors.generic.all
675     @errors.cyclades.connection
676     @errors.plankton.id
677     @errors.plankton.metadata
678     def _run(self, image_id, key):
679         self._optional_output(self.client.delete_image_metadata(image_id, key))
680
681     def main(self, image_id, key):
682         super(self.__class__, self)._run()
683         self._run(image_id=image_id, key=key)