Use container:path format in register
[kamaki] / kamaki / cli / commands / image.py
1 # Copyright 2012-2013 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_cloud(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>/<image-path>
157
158     :returns: (<user-id>, <container>, <image-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         container=ValueArgument(
309             'Pithos+ container containing the image file',
310             ('-C', '--container')),
311         uuid=ValueArgument('Custom user uuid', '--uuid')
312     )
313
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)
318         else:
319             astakos_url = self.config.get('user', 'url')\
320                 or self.config.get('astakos', 'url')
321             if not astakos_url:
322                 raise CLIBaseUrlError(service='astakos')
323             user = AstakosClient(astakos_url, atoken)
324             return user.term('id')
325
326     def _get_pithos_client(self, container):
327         if self['no_metafile_upload']:
328             return None
329         ptoken = self.client.token
330         if getattr(self, 'auth_base', False):
331             pithos_endpoints = self.auth_base.get_service_endpoints(
332                 'object-store')
333             purl = pithos_endpoints['publicURL']
334         else:
335             purl = self.config.get_cloud('pithos', 'url')
336         if not purl:
337             raise CLIBaseUrlError(service='pithos')
338         return PithosClient(purl, ptoken, self._get_user_id(), container)
339
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))
343
344     def _load_params_from_file(self, location):
345         params, properties = dict(), dict()
346         pfile = self['metafile']
347         if pfile:
348             try:
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
354                     elif key == 'name':
355                             continue
356                     elif key == 'location':
357                         if location:
358                             continue
359                         location = v
360                     else:
361                         params[key] = v
362             except Exception as e:
363                 raiseCLIError(e, 'Invalid json metadata config file')
364         return params, properties, location
365
366     def _load_params_from_args(self, params, properties):
367         for key in set([
368                 'checksum',
369                 'container_format',
370                 'disk_format',
371                 'owner',
372                 'size',
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
377
378     def _validate_location(self, location):
379         if not location:
380             raiseCLIError(
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)
387         try:
388             return _validate_image_location(location)
389         except AssertionError as ae:
390             raiseCLIError(
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)
396
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)):
403             raiseCLIError(
404                 'Incorrect container-path format', importance=1, details=[
405                 'Use : to seperate container form path',
406                 '  <container>:<image-path>',
407                 'OR',
408                 'Use -C to specifiy a container',
409                 '  -C <container> <image-path>'] + howto_image_file)
410
411         return uuid, container, path
412
413     @errors.generic.all
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)
422
423         #check if metafile exists
424         meta_path = '%s.meta' % img_path
425         if pclient and not self['metafile_force']:
426             try:
427                 pclient.get_object_info(meta_path)
428                 raiseCLIError(
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:
433                 if ce.status != 404:
434                     raise
435
436         #register the image
437         try:
438             r = self.client.register(name, location, params, properties)
439         except ClientError as ce:
440             if ce.status in (400, ):
441                 raiseCLIError(
442                     ce, 'Nonexistent image file location %s' % location,
443                     details=[
444                         'Make sure the image file exists'] + howto_image_file)
445             raise
446         self._print(r, print_dict)
447
448         #upload the metadata file
449         if pclient:
450             try:
451                 meta_headers = pclient.upload_from_string(
452                     meta_path, dumps(r, indent=2))
453             except TypeError:
454                 print('Failed to dump metafile %s:%s' % (container, meta_path))
455                 return
456             if self['json_output']:
457                 print_json(dict(
458                     metafile_location='%s:%s' % (container, meta_path),
459                     headers=meta_headers))
460             else:
461                 print('Metadata file uploaded as %s:%s (version %s)' % (
462                     container, meta_path, meta_headers['x-object-version']))
463
464     def main(self, name, container___image_path):
465         super(self.__class__, self)._run()
466         self._run(name, *self._mine_location(container___image_path))
467
468
469 @command(image_cmds)
470 class image_unregister(_init_image, _optional_output_cmd):
471     """Unregister an image (does not delete the image file)"""
472
473     @errors.generic.all
474     @errors.plankton.connection
475     @errors.plankton.id
476     def _run(self, image_id):
477         self._optional_output(self.client.unregister(image_id))
478
479     def main(self, image_id):
480         super(self.__class__, self)._run()
481         self._run(image_id=image_id)
482
483
484 @command(image_cmds)
485 class image_shared(_init_image, _optional_json):
486     """List images shared by a member"""
487
488     @errors.generic.all
489     @errors.plankton.connection
490     def _run(self, member):
491         self._print(self.client.list_shared(member), title=('image_id',))
492
493     def main(self, member):
494         super(self.__class__, self)._run()
495         self._run(member)
496
497
498 @command(image_cmds)
499 class image_members(_init_image):
500     """Manage members. Members of an image are users who can modify it"""
501
502
503 @command(image_cmds)
504 class image_members_list(_init_image, _optional_json):
505     """List members of an image"""
506
507     @errors.generic.all
508     @errors.plankton.connection
509     @errors.plankton.id
510     def _run(self, image_id):
511         self._print(self.client.list_members(image_id), title=('member_id',))
512
513     def main(self, image_id):
514         super(self.__class__, self)._run()
515         self._run(image_id=image_id)
516
517
518 @command(image_cmds)
519 class image_members_add(_init_image, _optional_output_cmd):
520     """Add a member to an image"""
521
522     @errors.generic.all
523     @errors.plankton.connection
524     @errors.plankton.id
525     def _run(self, image_id=None, member=None):
526             self._optional_output(self.client.add_member(image_id, member))
527
528     def main(self, image_id, member):
529         super(self.__class__, self)._run()
530         self._run(image_id=image_id, member=member)
531
532
533 @command(image_cmds)
534 class image_members_delete(_init_image, _optional_output_cmd):
535     """Remove a member from an image"""
536
537     @errors.generic.all
538     @errors.plankton.connection
539     @errors.plankton.id
540     def _run(self, image_id=None, member=None):
541             self._optional_output(self.client.remove_member(image_id, member))
542
543     def main(self, image_id, member):
544         super(self.__class__, self)._run()
545         self._run(image_id=image_id, member=member)
546
547
548 @command(image_cmds)
549 class image_members_set(_init_image, _optional_output_cmd):
550     """Set the members of an image"""
551
552     @errors.generic.all
553     @errors.plankton.connection
554     @errors.plankton.id
555     def _run(self, image_id, members):
556             self._optional_output(self.client.set_members(image_id, members))
557
558     def main(self, image_id, *members):
559         super(self.__class__, self)._run()
560         self._run(image_id=image_id, members=members)
561
562
563 # Compute Image Commands
564
565
566 @command(image_cmds)
567 class image_compute(_init_cyclades):
568     """Cyclades/Compute API image commands"""
569
570
571 @command(image_cmds)
572 class image_compute_list(_init_cyclades, _optional_json):
573     """List images"""
574
575     arguments = dict(
576         detail=FlagArgument('show detailed output', ('-l', '--details')),
577         limit=IntArgument('limit number listed images', ('-n', '--number')),
578         more=FlagArgument(
579             'output results in pages (-n to set items per page, default 10)',
580             '--more'),
581         enum=FlagArgument('Enumerate results', '--enumerate')
582     )
583
584     @errors.generic.all
585     @errors.cyclades.connection
586     def _run(self):
587         images = self.client.list_images(self['detail'])
588         kwargs = dict(with_enumeration=self['enum'])
589         if self['more']:
590             kwargs['page_size'] = self['limit'] or 10
591         elif self['limit']:
592             images = images[:self['limit']]
593         self._print(images, **kwargs)
594
595     def main(self):
596         super(self.__class__, self)._run()
597         self._run()
598
599
600 @command(image_cmds)
601 class image_compute_info(_init_cyclades, _optional_json):
602     """Get detailed information on an image"""
603
604     @errors.generic.all
605     @errors.cyclades.connection
606     @errors.plankton.id
607     def _run(self, image_id):
608         image = self.client.get_image_details(image_id)
609         self._print(image, print_dict)
610
611     def main(self, image_id):
612         super(self.__class__, self)._run()
613         self._run(image_id=image_id)
614
615
616 @command(image_cmds)
617 class image_compute_delete(_init_cyclades, _optional_output_cmd):
618     """Delete an image (WARNING: image file is also removed)"""
619
620     @errors.generic.all
621     @errors.cyclades.connection
622     @errors.plankton.id
623     def _run(self, image_id):
624         self._optional_output(self.client.delete_image(image_id))
625
626     def main(self, image_id):
627         super(self.__class__, self)._run()
628         self._run(image_id=image_id)
629
630
631 @command(image_cmds)
632 class image_compute_properties(_init_cyclades):
633     """Manage properties related to OS installation in an image"""
634
635
636 @command(image_cmds)
637 class image_compute_properties_list(_init_cyclades, _optional_json):
638     """List all image properties"""
639
640     @errors.generic.all
641     @errors.cyclades.connection
642     @errors.plankton.id
643     def _run(self, image_id):
644         self._print(self.client.get_image_metadata(image_id), print_dict)
645
646     def main(self, image_id):
647         super(self.__class__, self)._run()
648         self._run(image_id=image_id)
649
650
651 @command(image_cmds)
652 class image_compute_properties_get(_init_cyclades, _optional_json):
653     """Get an image property"""
654
655     @errors.generic.all
656     @errors.cyclades.connection
657     @errors.plankton.id
658     @errors.plankton.metadata
659     def _run(self, image_id, key):
660         self._print(self.client.get_image_metadata(image_id, key), print_dict)
661
662     def main(self, image_id, key):
663         super(self.__class__, self)._run()
664         self._run(image_id=image_id, key=key)
665
666
667 @command(image_cmds)
668 class image_compute_properties_add(_init_cyclades, _optional_json):
669     """Add a property to an image"""
670
671     @errors.generic.all
672     @errors.cyclades.connection
673     @errors.plankton.id
674     @errors.plankton.metadata
675     def _run(self, image_id, key, val):
676         self._print(
677             self.client.create_image_metadata(image_id, key, val), print_dict)
678
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)
682
683
684 @command(image_cmds)
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
689     """
690
691     @errors.generic.all
692     @errors.cyclades.connection
693     @errors.plankton.id
694     def _run(self, image_id, keyvals):
695         meta = dict()
696         for keyval in keyvals:
697             key, val = keyval.split('=')
698             meta[key] = val
699         self._print(
700             self.client.update_image_metadata(image_id, **meta), print_dict)
701
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)
705
706
707 @command(image_cmds)
708 class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
709     """Delete a property from an image"""
710
711     @errors.generic.all
712     @errors.cyclades.connection
713     @errors.plankton.id
714     @errors.plankton.metadata
715     def _run(self, image_id, key):
716         self._optional_output(self.client.delete_image_metadata(image_id, key))
717
718     def main(self, image_id, key):
719         super(self.__class__, self)._run()
720         self._run(image_id=image_id, key=key)