Undo server restructs, keep the big fixes
[kamaki] / kamaki / cli / commands / image.py
index 6f6cfc6..a583f9c 100644 (file)
@@ -1,4 +1,4 @@
-# Copyright 2012 GRNET S.A. All rights reserved.
+# Copyright 2012-2013 GRNET S.A. All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or
 # without modification, are permitted provided that the following
 # or implied, of GRNET S.A.command
 
 from json import load, dumps
-from os.path import abspath
+from os import path
 from logging import getLogger
+from io import StringIO
+from pydoc import pager
 
 from kamaki.cli import command
 from kamaki.cli.command_tree import CommandTree
-from kamaki.cli.utils import print_dict, print_json
+from kamaki.cli.utils import filter_dicts_by_dict
 from kamaki.clients.image import ImageClient
 from kamaki.clients.pithos import PithosClient
-from kamaki.clients.astakos import AstakosClient
 from kamaki.clients import ClientError
-from kamaki.cli.argument import FlagArgument, ValueArgument, KeyValueArgument
-from kamaki.cli.argument import IntArgument
+from kamaki.cli.argument import (
+    FlagArgument, ValueArgument, RepeatableArgument, KeyValueArgument,
+    IntArgument, ProgressBarArgument)
 from kamaki.cli.commands.cyclades import _init_cyclades
-from kamaki.cli.errors import raiseCLIError, CLIBaseUrlError
+from kamaki.cli.errors import (
+    raiseCLIError, CLIBaseUrlError, CLIInvalidArgument)
 from kamaki.cli.commands import _command_init, errors, addLogSettings
-from kamaki.cli.commands import _optional_output_cmd, _optional_json
+from kamaki.cli.commands import (
+    _optional_output_cmd, _optional_json, _name_filter, _id_filter)
 
 
-image_cmds = CommandTree(
-    'image',
-    'Cyclades/Plankton API image commands\n'
-    'image compute:\tCyclades/Compute API image commands')
-_commands = [image_cmds]
+image_cmds = CommandTree('image', 'Cyclades/Plankton API image commands')
+imagecompute_cmds = CommandTree(
+    'imagecompute', 'Cyclades/Compute API image commands')
+_commands = [image_cmds, imagecompute_cmds]
 
 
 howto_image_file = [
     'Kamaki commands to:',
-    ' get current user id: /user authenticate',
-    ' check available containers: /file list',
-    ' create a new container: /file create <container>',
-    ' check container contents: /file list <container>',
-    ' upload files: /file upload <image file> <container>']
+    ' get current user id: kamaki user info',
+    ' check available containers: kamaki container list',
+    ' create a new container: kamaki container create CONTAINER',
+    ' check container contents: kamaki file list /CONTAINER',
+    ' upload files: kamaki file upload IMAGE_FILE /CONTAINER[/PATH]',
+    ' register an image:',
+    '   kamaki image register --name=IMAGE_NAME --location=/CONTAINER/PATH']
 
 about_image_id = ['To see a list of available image ids: /image list']
 
@@ -78,9 +83,8 @@ class _init_image(_command_init):
         if getattr(self, 'cloud', None):
             img_url = self._custom_url('image') or self._custom_url('plankton')
             if img_url:
-                token = self._custom_token('image')\
-                    or self._custom_token('plankton')\
-                    or self.config.get_cloud(self.cloud, 'token')
+                token = self._custom_token('image') or self._custom_token(
+                    'plankton') or self.config.get_cloud(self.cloud, 'token')
                 self.client = ImageClient(base_url=img_url, token=token)
                 return
         if getattr(self, 'auth_base', False):
@@ -142,7 +146,7 @@ def _load_image_meta(filepath):
 
     :raises AssertionError: Valid json but invalid image properties dict
     """
-    with open(abspath(filepath)) as f:
+    with open(path.abspath(filepath)) as f:
         meta_dict = load(f)
         try:
             return _validate_image_meta(meta_dict)
@@ -153,9 +157,9 @@ def _load_image_meta(filepath):
 
 def _validate_image_location(location):
     """
-    :param location: (str) pithos://<user-id>/<container>/<img-file-path>
+    :param location: (str) pithos://<user-id>/<container>/<image-path>
 
-    :returns: (<user-id>, <container>, <img-file-path>)
+    :returns: (<user-id>, <container>, <image-path>)
 
     :raises AssertionError: if location is invalid
     """
@@ -172,29 +176,24 @@ def _validate_image_location(location):
 
 
 @command(image_cmds)
-class image_list(_init_image, _optional_json):
+class image_list(_init_image, _optional_json, _name_filter, _id_filter):
     """List images accessible by user"""
 
+    PERMANENTS = (
+        'id', 'name',
+        'status', 'container_format', 'disk_format', 'size')
+
     arguments = dict(
         detail=FlagArgument('show detailed output', ('-l', '--details')),
         container_format=ValueArgument(
             'filter by container format',
             '--container-format'),
         disk_format=ValueArgument('filter by disk format', '--disk-format'),
-        name=ValueArgument('filter by name', '--name'),
-        name_pref=ValueArgument(
-            'filter by name prefix (case insensitive)',
-            '--name-prefix'),
-        name_suff=ValueArgument(
-            'filter by name suffix (case insensitive)',
-            '--name-suffix'),
-        name_like=ValueArgument(
-            'print only if name contains this (case insensitive)',
-            '--name-like'),
         size_min=IntArgument('filter by minimum size', '--size-min'),
         size_max=IntArgument('filter by maximum size', '--size-max'),
         status=ValueArgument('filter by status', '--status'),
         owner=ValueArgument('filter by owner', '--owner'),
+        owner_name=ValueArgument('filter by owners username', '--owner-name'),
         order=ValueArgument(
             'order by FIELD ( - to reverse order)',
             '--order',
@@ -203,32 +202,54 @@ class image_list(_init_image, _optional_json):
         more=FlagArgument(
             'output results in pages (-n to set items per page, default 10)',
             '--more'),
-        enum=FlagArgument('Enumerate results', '--enumerate')
+        enum=FlagArgument('Enumerate results', '--enumerate'),
+        prop=KeyValueArgument('filter by property key=value', ('--property')),
+        prop_like=KeyValueArgument(
+            'fliter by property key=value where value is part of actual value',
+            ('--property-like')),
+        image_ID_for_members=ValueArgument(
+            'List members of an image', '--members-of'),
     )
 
-    def _filtered_by_owner(self, detail, *list_params):
-        images = []
-        MINKEYS = set([
-            'id', 'size', 'status', 'disk_format', 'container_format', 'name'])
-        for img in self.client.list_public(True, *list_params):
-            if img['owner'] == self['owner']:
-                if not detail:
-                    for key in set(img.keys()).difference(MINKEYS):
-                        img.pop(key)
-                images.append(img)
+    def _filter_by_owner(self, images):
+        ouuid = self['owner'] or self._username2uuid(self['owner_name'])
+        return filter_dicts_by_dict(images, dict(owner=ouuid))
+
+    def _add_owner_name(self, images):
+        uuids = self._uuids2usernames(
+            list(set([img['owner'] for img in images])))
+        for img in images:
+            img['owner'] += ' (%s)' % uuids[img['owner']]
         return images
 
-    def _filtered_by_name(self, images):
-        np, ns, nl = self['name_pref'], self['name_suff'], self['name_like']
-        return [img for img in images if (
-            (not np) or img['name'].lower().startswith(np.lower())) and (
-            (not ns) or img['name'].lower().endswith(ns.lower())) and (
-            (not nl) or nl.lower() in img['name'].lower())]
+    def _filter_by_properties(self, images):
+        new_images = []
+        for img in images:
+            props = [dict(img['properties'])]
+            if self['prop']:
+                props = filter_dicts_by_dict(props, self['prop'])
+            if props and self['prop_like']:
+                props = filter_dicts_by_dict(
+                    props, self['prop_like'], exact_match=False)
+            if props:
+                new_images.append(img)
+        return new_images
+
+    def _members(self, image_id):
+        members = self.client.list_members(image_id)
+        if not (self['json_output'] or self['output_format']):
+            uuids = [member['member_id'] for member in members]
+            usernames = self._uuids2usernames(uuids)
+            for member in members:
+                member['member_id'] += ' (%s)' % usernames[member['member_id']]
+        self._print(members, title=('member_id',))
 
     @errors.generic.all
     @errors.cyclades.connection
     def _run(self):
         super(self.__class__, self)._run()
+        if self['image_ID_for_members']:
+            return self._members(self['image_ID_for_members'])
         filters = {}
         for arg in set([
                 'container_format',
@@ -240,19 +261,35 @@ class image_list(_init_image, _optional_json):
             filters[arg] = self[arg]
 
         order = self['order']
-        detail = self['detail']
-        if self['owner']:
-            images = self._filtered_by_owner(detail, filters, order)
-        else:
-            images = self.client.list_public(detail, filters, order)
-
-        images = self._filtered_by_name(images)
+        detail = self['detail'] or (
+            self['prop'] or self['prop_like']) or (
+            self['owner'] or self['owner_name'])
+
+        images = self.client.list_public(detail, filters, order)
+
+        if self['owner'] or self['owner_name']:
+            images = self._filter_by_owner(images)
+        if self['prop'] or self['prop_like']:
+            images = self._filter_by_properties(images)
+        images = self._filter_by_id(images)
+        images = self._non_exact_name_filter(images)
+
+        if self['detail'] and not (
+                self['json_output'] or self['output_format']):
+            images = self._add_owner_name(images)
+        elif detail and not self['detail']:
+            for img in images:
+                for key in set(img).difference(self.PERMANENTS):
+                    img.pop(key)
         kwargs = dict(with_enumeration=self['enum'])
-        if self['more']:
-            kwargs['page_size'] = self['limit'] or 10
-        elif self['limit']:
+        if self['limit']:
             images = images[:self['limit']]
+        if self['more']:
+            kwargs['out'] = StringIO()
+            kwargs['title'] = ()
         self._print(images, **kwargs)
+        if self['more']:
+            pager(kwargs['out'].getvalue())
 
     def main(self):
         super(self.__class__, self)._run()
@@ -260,69 +297,186 @@ class image_list(_init_image, _optional_json):
 
 
 @command(image_cmds)
-class image_meta(_init_image, _optional_json):
-    """Get image metadata
-    Image metadata include:
-    - image file information (location, size, etc.)
-    - image information (id, name, etc.)
-    - image os properties (os, fs, etc.)
+class image_info(_init_image, _optional_json):
+    """Get image metadata"""
+
+    @errors.generic.all
+    @errors.plankton.connection
+    @errors.plankton.id
+    def _run(self, image_id):
+        meta = self.client.get_meta(image_id)
+        if not (self['json_output'] or self['output_format']):
+            meta['owner'] += ' (%s)' % self._uuid2username(meta['owner'])
+        self._print(meta, self.print_dict)
+
+    def main(self, image_id):
+        super(self.__class__, self)._run()
+        self._run(image_id=image_id)
+
+
+@command(image_cmds)
+class image_modify(_init_image, _optional_output_cmd):
+    """Add / update metadata and properties for an image
+    The original image preserves the values that are not affected
     """
 
+    arguments = dict(
+        image_name=ValueArgument('Change name', '--name'),
+        disk_format=ValueArgument('Change disk format', '--disk-format'),
+        container_format=ValueArgument(
+            'Change container format', '--container-format'),
+        status=ValueArgument('Change status', '--status'),
+        publish=FlagArgument('Make the image public', '--public'),
+        unpublish=FlagArgument('Make the image private', '--private'),
+        property_to_set=KeyValueArgument(
+            'set property in key=value form (can be repeated)',
+            ('-p', '--property-set')),
+        property_to_del=RepeatableArgument(
+            'Delete property by key (can be repeated)', '--property-del'),
+        member_ID_to_add=RepeatableArgument(
+            'Add member to image (can be repeated)', '--member-add'),
+        member_ID_to_remove=RepeatableArgument(
+            'Remove a member (can be repeated)', '--member-del'),
+    )
+    required = [
+        'image_name', 'disk_format', 'container_format', 'status', 'publish',
+        'unpublish', 'property_to_set', 'member_ID_to_add',
+        'member_ID_to_remove', 'property_to_del']
+
     @errors.generic.all
     @errors.plankton.connection
     @errors.plankton.id
     def _run(self, image_id):
-        self._print([self.client.get_meta(image_id)])
+        for mid in (self['member_ID_to_add'] or []):
+            self.client.add_member(image_id, mid)
+        for mid in (self['member_ID_to_remove'] or []):
+            self.client.remove_member(image_id, mid)
+        meta = self.client.get_meta(image_id)
+        for k, v in self['property_to_set'].items():
+            meta['properties'][k.upper()] = v
+        for k in (self['property_to_del'] or []):
+            meta['properties'][k.upper()] = None
+        self._optional_output(self.client.update_image(
+            image_id,
+            name=self['image_name'],
+            disk_format=self['disk_format'],
+            container_format=self['container_format'],
+            status=self['status'],
+            public=self['publish'] or (False if self['unpublish'] else None),
+            **meta['properties']))
+        if self['with_output']:
+            self._optional_output(self.get_image_details(image_id))
 
     def main(self, image_id):
         super(self.__class__, self)._run()
         self._run(image_id=image_id)
 
 
+class PithosLocationArgument(ValueArgument):
+    """Resolve pithos URI, return in the form pithos://uuid/container[/path]
+
+    UPDATE: URLs without a path are also resolvable. Therefore, caller methods
+    should check if there is a path or not
+    """
+
+    def __init__(
+            self, help=None, parsed_name=None, default=None, user_uuid=None):
+        super(PithosLocationArgument, self).__init__(
+            help=help, parsed_name=parsed_name, default=default)
+        self.uuid, self.container, self.path = user_uuid, None, None
+
+    def setdefault(self, term, value):
+        if not getattr(self, term, None):
+            setattr(self, term, value)
+
+    @property
+    def value(self):
+        path = ('/%s' % self.path) if self.path else ''
+        return 'pithos://%s/%s%s' % (self.uuid, self.container, path)
+
+    @value.setter
+    def value(self, location):
+        if location:
+            from kamaki.cli.commands.pithos import _pithos_container as pc
+            try:
+                uuid, self.container, self.path = pc._resolve_pithos_url(
+                    location)
+                self.uuid = uuid or self.uuid
+                assert self.container, 'No container in pithos URI'
+            except Exception as e:
+                raise CLIInvalidArgument(
+                    'Invalid Pithos+ location %s (%s)' % (location, e),
+                    details=[
+                        'The image location must be a valid Pithos+',
+                        'location. There are two valid formats:',
+                        '  pithos://USER_UUID/CONTAINER[/PATH]',
+                        'OR',
+                        '  /CONTAINER[/PATH]',
+                        'To see all containers:',
+                        '  [kamaki] container list',
+                        'To list the contents of a container:',
+                        '  [kamaki] container list CONTAINER'])
+
+
 @command(image_cmds)
 class image_register(_init_image, _optional_json):
-    """(Re)Register an image"""
+    """(Re)Register an image file to an Image service
+    The image file must be stored at a pithos repository
+    Some metadata can be set by user (e.g., disk-format) while others are set
+    only automatically (e.g., image id). There are also some custom user
+    metadata, called properties.
+    A register command creates a remote meta file at
+    /<container>/<image path>.meta
+    Users may download and edit this file and use it to re-register one or more
+    images.
+    In case of a meta file, runtime arguments for metadata or properties
+    override meta file settings.
+    """
+
+    container_info_cache = {}
 
     arguments = dict(
-        checksum=ValueArgument('set image checksum', '--checksum'),
+        checksum=ValueArgument('Set image checksum', '--checksum'),
         container_format=ValueArgument(
-            'set container format',
-            '--container-format'),
-        disk_format=ValueArgument('set disk format', '--disk-format'),
-        owner=ValueArgument('set image owner (admin only)', '--owner'),
+            'Set container format', '--container-format'),
+        disk_format=ValueArgument('Set disk format', '--disk-format'),
+        owner_name=ValueArgument('Set user uuid by user name', '--owner-name'),
         properties=KeyValueArgument(
-            'add property in key=value form (can be repeated)',
+            'Add property (user-specified metadata) in key=value form'
+            '(can be repeated)',
             ('-p', '--property')),
-        is_public=FlagArgument('mark image as public', '--public'),
-        size=IntArgument('set image size', '--size'),
+        is_public=FlagArgument('Mark image as public', '--public'),
+        size=IntArgument('Set image size in bytes', '--size'),
         metafile=ValueArgument(
             'Load metadata from a json-formated file <img-file>.meta :'
             '{"key1": "val1", "key2": "val2", ..., "properties: {...}"}',
             ('--metafile')),
-        metafile_force=FlagArgument(
-            'Store remote metadata object, even if it already exists',
+        force_upload=FlagArgument(
+            'Overwrite remote files (image file, metadata file)',
             ('-f', '--force')),
         no_metafile_upload=FlagArgument(
             'Do not store metadata in remote meta file',
             ('--no-metafile-upload')),
-
+        container=ValueArgument(
+            'Pithos+ container containing the image file',
+            ('-C', '--container')),
+        uuid=ValueArgument('Custom user uuid', '--uuid'),
+        local_image_path=ValueArgument(
+            'Local image file path to upload and register '
+            '(still need target file in the form /container/remote-path )',
+            '--upload-image-file'),
+        progress_bar=ProgressBarArgument(
+            'Do not use progress bar', '--no-progress-bar', default=False),
+        name=ValueArgument('The name of the new image', '--name'),
+        pithos_location=PithosLocationArgument(
+            'The Pithos+ image location to put the image at. Format:       '
+            'pithos://USER_UUID/CONTAINER/IMAGE                  or   '
+            '/CONTAINER/IMAGE',
+            '--location')
     )
+    required = ('name', 'pithos_location')
 
-    def _get_user_id(self):
-        atoken = self.client.token
-        if getattr(self, 'auth_base', False):
-            return self.auth_base.term('id', atoken)
-        else:
-            astakos_url = self.config.get('user', 'url')\
-                or self.config.get('astakos', 'url')
-            if not astakos_url:
-                raise CLIBaseUrlError(service='astakos')
-            user = AstakosClient(astakos_url, atoken)
-            return user.term('id')
-
-    def _get_pithos_client(self, container):
-        if self['no_metafile_upload']:
-            return None
+    def _get_pithos_client(self, locator):
         ptoken = self.client.token
         if getattr(self, 'auth_base', False):
             pithos_endpoints = self.auth_base.get_service_endpoints(
@@ -332,11 +486,7 @@ class image_register(_init_image, _optional_json):
             purl = self.config.get_cloud('pithos', 'url')
         if not purl:
             raise CLIBaseUrlError(service='pithos')
-        return PithosClient(purl, ptoken, self._get_user_id(), container)
-
-    def _store_remote_metafile(self, pclient, remote_path, metadata):
-        return pclient.upload_from_string(
-            remote_path, _validate_image_meta(metadata, return_str=True))
+        return PithosClient(purl, ptoken, locator.uuid, locator.container)
 
     def _load_params_from_file(self, location):
         params, properties = dict(), dict()
@@ -345,7 +495,7 @@ class image_register(_init_image, _optional_json):
             try:
                 for k, v in _load_image_meta(pfile).items():
                     key = k.lower().replace('-', '_')
-                    if k == 'properties':
+                    if key == 'properties':
                         for pk, pv in v.items():
                             properties[pk.upper().replace('-', '_')] = pv
                     elif key == 'name':
@@ -372,75 +522,113 @@ class image_register(_init_image, _optional_json):
         for k, v in self['properties'].items():
             properties[k.upper().replace('-', '_')] = v
 
-    def _validate_location(self, location):
-        if not location:
-            raiseCLIError(
-                'No image file location provided',
-                importance=2, details=[
-                    'An image location is needed. Image location format:',
-                    '  pithos://<user-id>/<container>/<path>',
-                    ' an image file at the above location must exist.'
-                    ] + howto_image_file)
-        try:
-            return _validate_image_location(location)
-        except AssertionError as ae:
-            raiseCLIError(
-                ae, 'Invalid image location format',
-                importance=1, details=[
-                    'Valid image location format:',
-                    '  pithos://<user-id>/<container>/<img-file-path>'
-                    ] + howto_image_file)
+    def _assert_remote_file_not_exist(self, pithos, path):
+        if pithos and not self['force_upload']:
+            try:
+                pithos.get_object_info(path)
+                raiseCLIError(
+                    'Remote file /%s/%s already exists' % (
+                        pithos.container, path),
+                    importance=2,
+                    details=[
+                        'Registration ABORTED',
+                        'Use %s to force upload' % self.arguments[
+                            'force_upload'].lvalue])
+            except ClientError as ce:
+                if ce.status != 404:
+                    raise
 
     @errors.generic.all
     @errors.plankton.connection
-    def _run(self, name, location):
-        (params, properties, location) = self._load_params_from_file(location)
-        uuid, container, img_path = self._validate_location(location)
+    def _run(self, name, locator):
+        location, pithos = locator.value, None
+        if self['local_image_path']:
+            with open(self['local_image_path']) as f:
+                pithos = self._get_pithos_client(locator)
+                self._assert_remote_file_not_exist(pithos, locator.path)
+                (pbar, upload_cb) = self._safe_progress_bar('Uploading')
+                if pbar:
+                    hash_bar = pbar.clone()
+                    hash_cb = hash_bar.get_generator('Calculating hashes')
+                pithos.upload_object(
+                    locator.path, f,
+                    hash_cb=hash_cb, upload_cb=upload_cb,
+                    container_info_cache=self.container_info_cache)
+                pbar.finish()
+
+        (params, properties, new_loc) = self._load_params_from_file(location)
+        if location != new_loc:
+            locator.value = new_loc
         self._load_params_from_args(params, properties)
-        pclient = self._get_pithos_client(container)
 
-        #check if metafile exists
-        meta_path = '%s.meta' % img_path
-        if pclient and not self['metafile_force']:
-            try:
-                pclient.get_object_info(meta_path)
-                raiseCLIError('Metadata file %s:%s already exists' % (
-                    container, meta_path))
-            except ClientError as ce:
-                if ce.status != 404:
-                    raise
+        if not self['no_metafile_upload']:
+            #check if metafile exists
+            pithos = pithos or self._get_pithos_client(locator)
+            meta_path = '%s.meta' % locator.path
+            self._assert_remote_file_not_exist(pithos, meta_path)
 
         #register the image
         try:
             r = self.client.register(name, location, params, properties)
         except ClientError as ce:
-            if ce.status in (400, ):
+            if ce.status in (400, 404):
                 raiseCLIError(
-                    ce, 'Nonexistent image file location %s' % location,
+                    ce, 'Nonexistent image file location\n\t%s' % location,
                     details=[
-                        'Make sure the image file exists'] + howto_image_file)
+                        'Does the image file %s exist at container %s ?' % (
+                            locator.path,
+                            locator.container)] + howto_image_file)
             raise
-        self._print(r, print_dict)
+        r['owner'] += ' (%s)' % self._uuid2username(r['owner'])
+        self._print(r, self.print_dict)
 
         #upload the metadata file
-        if pclient:
+        if not self['no_metafile_upload']:
             try:
-                meta_headers = pclient.upload_from_string(
-                    meta_path, dumps(r, indent=2))
+                meta_headers = pithos.upload_from_string(
+                    meta_path, dumps(r, indent=2),
+                    sharing=dict(read='*' if params.get('is_public') else ''),
+                    container_info_cache=self.container_info_cache)
             except TypeError:
-                print('Failed to dump metafile %s:%s' % (container, meta_path))
+                self.error(
+                    'Failed to dump metafile /%s/%s' % (
+                        locator.container, meta_path))
                 return
-            if self['json_output']:
-                print_json(dict(
-                    metafile_location='%s:%s' % (container, meta_path),
+            if self['json_output'] or self['output_format']:
+                self.print_json(dict(
+                    metafile_location='/%s/%s' % (
+                        locator.container, meta_path),
                     headers=meta_headers))
             else:
-                print('Metadata file uploaded as %s:%s (version %s)' % (
-                    container, meta_path, meta_headers['x-object-version']))
+                self.error('Metadata file uploaded as /%s/%s (version %s)' % (
+                    locator.container,
+                    meta_path,
+                    meta_headers['x-object-version']))
 
-    def main(self, name, location):
+    def main(self):
         super(self.__class__, self)._run()
-        self._run(name, location)
+
+        locator, pithos = self.arguments['pithos_location'], None
+        locator.setdefault('uuid', self.auth_base.user_term('id'))
+        locator.path = locator.path or path.basename(
+            self['local_image_path'] or '')
+        if not locator.path:
+            raise CLIInvalidArgument(
+                'Missing the image file or object', details=[
+                    'Pithos+ URI %s does not point to a physical image' % (
+                        locator.value),
+                    'A physical image is necessary.',
+                    'It can be a remote Pithos+ object or a local file.',
+                    'To specify a remote image object:',
+                    '  %s [pithos://UUID]/CONTAINER/PATH' % locator.lvalue,
+                    'To specify a local file:',
+                    '  %s [pithos://UUID]/CONTAINER[/PATH] %s LOCAL_PATH' % (
+                        locator.lvalue,
+                        self.arguments['local_image_path'].lvalue)
+                ])
+        self.arguments['pithos_location'].setdefault(
+            'uuid', self.auth_base.user_term('id'))
+        self._run(self['name'], locator)
 
 
 @command(image_cmds)
@@ -458,124 +646,90 @@ class image_unregister(_init_image, _optional_output_cmd):
         self._run(image_id=image_id)
 
 
-@command(image_cmds)
-class image_shared(_init_image, _optional_json):
-    """List images shared by a member"""
-
-    @errors.generic.all
-    @errors.plankton.connection
-    def _run(self, member):
-        self._print(self.client.list_shared(member), title=('image_id',))
-
-    def main(self, member):
-        super(self.__class__, self)._run()
-        self._run(member)
-
-
-@command(image_cmds)
-class image_members(_init_image):
-    """Manage members. Members of an image are users who can modify it"""
-
-
-@command(image_cmds)
-class image_members_list(_init_image, _optional_json):
-    """List members of an image"""
-
-    @errors.generic.all
-    @errors.plankton.connection
-    @errors.plankton.id
-    def _run(self, image_id):
-        self._print(self.client.list_members(image_id), title=('member_id',))
-
-    def main(self, image_id):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id)
-
-
-@command(image_cmds)
-class image_members_add(_init_image, _optional_output_cmd):
-    """Add a member to an image"""
-
-    @errors.generic.all
-    @errors.plankton.connection
-    @errors.plankton.id
-    def _run(self, image_id=None, member=None):
-            self._optional_output(self.client.add_member(image_id, member))
-
-    def main(self, image_id, member):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id, member=member)
-
-
-@command(image_cmds)
-class image_members_delete(_init_image, _optional_output_cmd):
-    """Remove a member from an image"""
-
-    @errors.generic.all
-    @errors.plankton.connection
-    @errors.plankton.id
-    def _run(self, image_id=None, member=None):
-            self._optional_output(self.client.remove_member(image_id, member))
-
-    def main(self, image_id, member):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id, member=member)
-
-
-@command(image_cmds)
-class image_members_set(_init_image, _optional_output_cmd):
-    """Set the members of an image"""
-
-    @errors.generic.all
-    @errors.plankton.connection
-    @errors.plankton.id
-    def _run(self, image_id, members):
-            self._optional_output(self.client.set_members(image_id, members))
-
-    def main(self, image_id, *members):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id, members=members)
-
-
 # Compute Image Commands
 
-
-@command(image_cmds)
-class image_compute(_init_cyclades):
-    """Cyclades/Compute API image commands"""
-
-
-@command(image_cmds)
-class image_compute_list(_init_cyclades, _optional_json):
+@command(imagecompute_cmds)
+class imagecompute_list(
+        _init_cyclades, _optional_json, _name_filter, _id_filter):
     """List images"""
 
+    PERMANENTS = ('id', 'name')
+
     arguments = dict(
         detail=FlagArgument('show detailed output', ('-l', '--details')),
         limit=IntArgument('limit number listed images', ('-n', '--number')),
-        more=FlagArgument(
-            'output results in pages (-n to set items per page, default 10)',
-            '--more'),
-        enum=FlagArgument('Enumerate results', '--enumerate')
+        more=FlagArgument('handle long lists of results', '--more'),
+        enum=FlagArgument('Enumerate results', '--enumerate'),
+        user_id=ValueArgument('filter by user_id', '--user-id'),
+        user_name=ValueArgument('filter by username', '--user-name'),
+        meta=KeyValueArgument(
+            'filter by metadata key=value (can be repeated)', ('--metadata')),
+        meta_like=KeyValueArgument(
+            'filter by metadata key=value (can be repeated)',
+            ('--metadata-like'))
     )
 
+    def _filter_by_metadata(self, images):
+        new_images = []
+        for img in images:
+            meta = [dict(img['metadata'])]
+            if self['meta']:
+                meta = filter_dicts_by_dict(meta, self['meta'])
+            if meta and self['meta_like']:
+                meta = filter_dicts_by_dict(
+                    meta, self['meta_like'], exact_match=False)
+            if meta:
+                new_images.append(img)
+        return new_images
+
+    def _filter_by_user(self, images):
+        uuid = self['user_id'] or self._username2uuid(self['user_name'])
+        return filter_dicts_by_dict(images, dict(user_id=uuid))
+
+    def _add_name(self, images, key='user_id'):
+        uuids = self._uuids2usernames(
+            list(set([img[key] for img in images])))
+        for img in images:
+            img[key] += ' (%s)' % uuids[img[key]]
+        return images
+
     @errors.generic.all
     @errors.cyclades.connection
     def _run(self):
-        images = self.client.list_images(self['detail'])
+        withmeta = bool(self['meta'] or self['meta_like'])
+        withuser = bool(self['user_id'] or self['user_name'])
+        detail = self['detail'] or withmeta or withuser
+        images = self.client.list_images(detail)
+        images = self._filter_by_name(images)
+        images = self._filter_by_id(images)
+        if withuser:
+            images = self._filter_by_user(images)
+        if withmeta:
+            images = self._filter_by_metadata(images)
+        if self['detail'] and not (
+                self['json_output'] or self['output_format']):
+            images = self._add_name(self._add_name(images, 'tenant_id'))
+        elif detail and not self['detail']:
+            for img in images:
+                for key in set(img).difference(self.PERMANENTS):
+                    img.pop(key)
         kwargs = dict(with_enumeration=self['enum'])
-        if self['more']:
-            kwargs['page_size'] = self['limit'] or 10
-        elif self['limit']:
+        if self['limit']:
             images = images[:self['limit']]
+        if self['more']:
+            kwargs['out'] = StringIO()
+            kwargs['title'] = ()
         self._print(images, **kwargs)
+        if self['more']:
+            pager(kwargs['out'].getvalue())
 
     def main(self):
         super(self.__class__, self)._run()
         self._run()
 
 
-@command(image_cmds)
-class image_compute_info(_init_cyclades, _optional_json):
+@command(imagecompute_cmds)
+class imagecompute_info(_init_cyclades, _optional_json):
     """Get detailed information on an image"""
 
     @errors.generic.all
@@ -583,15 +737,19 @@ class image_compute_info(_init_cyclades, _optional_json):
     @errors.plankton.id
     def _run(self, image_id):
         image = self.client.get_image_details(image_id)
-        self._print(image, print_dict)
+        uuids = [image['user_id'], image['tenant_id']]
+        usernames = self._uuids2usernames(uuids)
+        image['user_id'] += ' (%s)' % usernames[image['user_id']]
+        image['tenant_id'] += ' (%s)' % usernames[image['tenant_id']]
+        self._print(image, self.print_dict)
 
     def main(self, image_id):
         super(self.__class__, self)._run()
         self._run(image_id=image_id)
 
 
-@command(image_cmds)
-class image_compute_delete(_init_cyclades, _optional_output_cmd):
+@command(imagecompute_cmds)
+class imagecompute_delete(_init_cyclades, _optional_output_cmd):
     """Delete an image (WARNING: image file is also removed)"""
 
     @errors.generic.all
@@ -605,93 +763,32 @@ class image_compute_delete(_init_cyclades, _optional_output_cmd):
         self._run(image_id=image_id)
 
 
-@command(image_cmds)
-class image_compute_properties(_init_cyclades):
-    """Manage properties related to OS installation in an image"""
-
+@command(imagecompute_cmds)
+class imagecompute_modify(_init_cyclades, _optional_output_cmd):
+    """Modify image properties (metadata)"""
 
-@command(image_cmds)
-class image_compute_properties_list(_init_cyclades, _optional_json):
-    """List all image properties"""
+    arguments = dict(
+        property_to_add=KeyValueArgument(
+            'Add property in key=value format (can be repeated)',
+            ('--property-add')),
+        property_to_del=RepeatableArgument(
+            'Delete property by key (can be repeated)',
+            ('--property-del'))
+    )
+    required = ['property_to_add', 'property_to_del']
 
     @errors.generic.all
     @errors.cyclades.connection
     @errors.plankton.id
     def _run(self, image_id):
-        self._print(self.client.get_image_metadata(image_id), print_dict)
+        if self['property_to_add']:
+            self.client.update_image_metadata(
+                image_id, **self['property_to_add'])
+        for key in (self['property_to_del'] or []):
+            self.client.delete_image_metadata(image_id, key)
+        if self['with_output']:
+            self._optional_output(self.client.get_image_details(image_id))
 
     def main(self, image_id):
         super(self.__class__, self)._run()
         self._run(image_id=image_id)
-
-
-@command(image_cmds)
-class image_compute_properties_get(_init_cyclades, _optional_json):
-    """Get an image property"""
-
-    @errors.generic.all
-    @errors.cyclades.connection
-    @errors.plankton.id
-    @errors.plankton.metadata
-    def _run(self, image_id, key):
-        self._print(self.client.get_image_metadata(image_id, key), print_dict)
-
-    def main(self, image_id, key):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id, key=key)
-
-
-@command(image_cmds)
-class image_compute_properties_add(_init_cyclades, _optional_json):
-    """Add a property to an image"""
-
-    @errors.generic.all
-    @errors.cyclades.connection
-    @errors.plankton.id
-    @errors.plankton.metadata
-    def _run(self, image_id, key, val):
-        self._print(
-            self.client.create_image_metadata(image_id, key, val), print_dict)
-
-    def main(self, image_id, key, val):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id, key=key, val=val)
-
-
-@command(image_cmds)
-class image_compute_properties_set(_init_cyclades, _optional_json):
-    """Add / update a set of properties for an image
-    proeprties must be given in the form key=value, e.v.
-    /image compute properties set <image-id> key1=val1 key2=val2
-    """
-
-    @errors.generic.all
-    @errors.cyclades.connection
-    @errors.plankton.id
-    def _run(self, image_id, keyvals):
-        meta = dict()
-        for keyval in keyvals:
-            key, val = keyval.split('=')
-            meta[key] = val
-        self._print(
-            self.client.update_image_metadata(image_id, **meta), print_dict)
-
-    def main(self, image_id, *key_equals_value):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id, keyvals=key_equals_value)
-
-
-@command(image_cmds)
-class image_compute_properties_delete(_init_cyclades, _optional_output_cmd):
-    """Delete a property from an image"""
-
-    @errors.generic.all
-    @errors.cyclades.connection
-    @errors.plankton.id
-    @errors.plankton.metadata
-    def _run(self, image_id, key):
-        self._optional_output(self.client.delete_image_metadata(image_id, key))
-
-    def main(self, image_id, key):
-        super(self.__class__, self)._run()
-        self._run(image_id=image_id, key=key)