Elena Ezhova | 1c2ebae | 2017-07-03 18:29:05 +0400 | [diff] [blame] | 1 | # -*- coding: utf-8 -*- |
| 2 | """ |
| 3 | Module extending the salt.modules.glance modules. |
| 4 | |
| 5 | This module adds functionality for managing Glance V2 tasks by exposing the |
| 6 | following functions: |
| 7 | - task_create |
| 8 | - task_show |
| 9 | - task_list |
| 10 | |
| 11 | :optdepends: - glanceclient Python adapter |
| 12 | :configuration: This module is not usable until the following are specified |
| 13 | either in a pillar or in the minion's config file:: |
| 14 | |
| 15 | keystone.user: admin |
| 16 | keystone.password: verybadpass |
| 17 | keystone.tenant: admin |
| 18 | keystone.insecure: False #(optional) |
| 19 | keystone.auth_url: 'http://127.0.0.1:5000/v2.0/' |
| 20 | |
| 21 | If configuration for multiple openstack accounts is required, they can be |
| 22 | set up as different configuration profiles: |
| 23 | For example:: |
| 24 | |
| 25 | openstack1: |
| 26 | keystone.user: admin |
| 27 | keystone.password: verybadpass |
| 28 | keystone.tenant: admin |
| 29 | keystone.auth_url: 'http://127.0.0.1:5000/v2.0/' |
| 30 | |
| 31 | openstack2: |
| 32 | keystone.user: admin |
| 33 | keystone.password: verybadpass |
| 34 | keystone.tenant: admin |
| 35 | keystone.auth_url: 'http://127.0.0.2:5000/v2.0/' |
| 36 | |
| 37 | With this configuration in place, any of the glance functions can |
| 38 | make use of a configuration profile by declaring it explicitly. |
| 39 | For example:: |
| 40 | |
| 41 | salt '*' glance.image_list profile=openstack1 |
| 42 | """ |
| 43 | |
| 44 | # Import Python libs |
| 45 | from __future__ import absolute_import |
| 46 | import logging |
| 47 | import pprint |
| 48 | import re |
| 49 | |
| 50 | # Import salt libs |
| 51 | from salt.exceptions import SaltInvocationError |
| 52 | |
| 53 | from salt.version import ( |
| 54 | __version__, |
| 55 | SaltStackVersion |
| 56 | ) |
| 57 | # is there not SaltStackVersion.current() to get |
| 58 | # the version of the salt running this code?? |
| 59 | _version_ary = __version__.split('.') |
| 60 | CUR_VER = SaltStackVersion(_version_ary[0], _version_ary[1]) |
| 61 | BORON = SaltStackVersion.from_name('Boron') |
| 62 | |
| 63 | # pylint: disable=import-error |
| 64 | HAS_GLANCE = False |
| 65 | try: |
| 66 | from glanceclient import client |
| 67 | from glanceclient import exc |
| 68 | HAS_GLANCE = True |
| 69 | except ImportError: |
| 70 | pass |
| 71 | |
| 72 | # Workaround, as the Glance API v2 requires you to |
| 73 | # already have a keystone session token |
| 74 | HAS_KEYSTONE = False |
| 75 | try: |
| 76 | from keystoneclient.v2_0 import client as kstone |
| 77 | HAS_KEYSTONE = True |
| 78 | except ImportError: |
| 79 | pass |
| 80 | |
| 81 | |
| 82 | logging.basicConfig(level=logging.DEBUG) |
| 83 | log = logging.getLogger(__name__) |
| 84 | |
| 85 | |
| 86 | def __virtual__(): |
| 87 | ''' |
| 88 | Only load this module if glance |
| 89 | is installed on this minion. |
| 90 | ''' |
| 91 | if not HAS_GLANCE: |
| 92 | return False, ("The glance execution module cannot be loaded: " |
| 93 | "the glanceclient python library is not available.") |
| 94 | if not HAS_KEYSTONE: |
| 95 | return False, ("The keystone execution module cannot be loaded: " |
| 96 | "the keystoneclient python library is not available.") |
| 97 | return True |
| 98 | |
| 99 | |
| 100 | __opts__ = {} |
| 101 | |
| 102 | |
| 103 | def _auth(profile=None, api_version=2, **connection_args): |
| 104 | ''' |
| 105 | Set up glance credentials, returns |
| 106 | `glanceclient.client.Client`. Optional parameter |
| 107 | "api_version" defaults to 2. |
| 108 | |
| 109 | Only intended to be used within glance-enabled modules |
| 110 | ''' |
| 111 | |
| 112 | if profile: |
| 113 | prefix = profile + ":keystone." |
| 114 | else: |
| 115 | prefix = "keystone." |
| 116 | |
| 117 | def get(key, default=None): |
| 118 | ''' |
| 119 | Checks connection_args, then salt-minion config, |
| 120 | falls back to specified default value. |
| 121 | ''' |
| 122 | return connection_args.get('connection_' + key, |
| 123 | __salt__['config.get'](prefix + key, default)) |
| 124 | |
| 125 | user = get('user', 'admin') |
| 126 | password = get('password', None) |
| 127 | tenant = get('tenant', 'admin') |
| 128 | tenant_id = get('tenant_id') |
| 129 | auth_url = get('auth_url', 'http://127.0.0.1:35357/v2.0') |
| 130 | insecure = get('insecure', False) |
| 131 | admin_token = get('token') |
| 132 | region = get('region') |
| 133 | ks_endpoint = get('endpoint', 'http://127.0.0.1:9292/') |
| 134 | g_endpoint_url = __salt__['keystone.endpoint_get']('glance', profile) |
| 135 | # The trailing 'v2' causes URLs like thise one: |
| 136 | # http://127.0.0.1:9292/v2/v1/images |
| 137 | g_endpoint_url = re.sub('/v2', '', g_endpoint_url['internalurl']) |
| 138 | |
| 139 | if admin_token and api_version != 1 and not password: |
| 140 | # If we had a password we could just |
| 141 | # ignore the admin-token and move on... |
| 142 | raise SaltInvocationError('Only can use keystone admin token ' |
| 143 | 'with Glance API v1') |
| 144 | elif password: |
| 145 | # Can't use the admin-token anyway |
| 146 | kwargs = {'username': user, |
| 147 | 'password': password, |
| 148 | 'tenant_id': tenant_id, |
| 149 | 'auth_url': auth_url, |
| 150 | 'endpoint_url': g_endpoint_url, |
| 151 | 'region_name': region, |
| 152 | 'tenant_name': tenant} |
| 153 | # 'insecure' keyword not supported by all v2.0 keystone clients |
| 154 | # this ensures it's only passed in when defined |
| 155 | if insecure: |
| 156 | kwargs['insecure'] = True |
| 157 | elif api_version == 1 and admin_token: |
| 158 | kwargs = {'token': admin_token, |
| 159 | 'auth_url': auth_url, |
| 160 | 'endpoint_url': g_endpoint_url} |
| 161 | else: |
| 162 | raise SaltInvocationError('No credentials to authenticate with.') |
| 163 | |
| 164 | if HAS_KEYSTONE: |
| 165 | log.debug('Calling keystoneclient.v2_0.client.Client(' + |
| 166 | '{0}, **{1})'.format(ks_endpoint, kwargs)) |
| 167 | keystone = kstone.Client(**kwargs) |
| 168 | kwargs['token'] = keystone.get_token(keystone.session) |
| 169 | # This doesn't realy prevent the password to show up |
| 170 | # in the minion log as keystoneclient.session is |
| 171 | # logging it anyway when in debug-mode |
| 172 | kwargs.pop('password') |
| 173 | log.debug('Calling glanceclient.client.Client(' + |
| 174 | '{0}, {1}, **{2})'.format(api_version, |
| 175 | g_endpoint_url, kwargs)) |
| 176 | # may raise exc.HTTPUnauthorized, exc.HTTPNotFound |
| 177 | # but we deal with those elsewhere |
| 178 | return client.Client(api_version, g_endpoint_url, **kwargs) |
| 179 | else: |
| 180 | raise NotImplementedError( |
| 181 | "Can't retrieve a auth_token without keystone") |
| 182 | |
| 183 | |
| 184 | def _validate_image_params(visibility=None, container_format='bare', |
| 185 | disk_format='raw', tags=None, **kwargs): |
| 186 | # valid options for "visibility": |
| 187 | v_list = ['public', 'private', 'shared', 'community'] |
| 188 | # valid options for "container_format": |
| 189 | cf_list = ['ami', 'ari', 'aki', 'bare', 'ovf'] |
| 190 | # valid options for "disk_format": |
| 191 | df_list = ['ami', 'ari', 'aki', 'vhd', 'vmdk', |
| 192 | 'raw', 'qcow2', 'vdi', 'iso'] |
| 193 | |
| 194 | if visibility is not None: |
| 195 | if visibility not in v_list: |
| 196 | raise SaltInvocationError('"visibility" needs to be one ' + |
| 197 | 'of the following: {0}'.format( |
| 198 | ', '.join(v_list))) |
| 199 | if container_format not in cf_list: |
| 200 | raise SaltInvocationError('"container_format" needs to be ' + |
| 201 | 'one of the following: {0}'.format( |
| 202 | ', '.join(cf_list))) |
| 203 | if disk_format not in df_list: |
| 204 | raise SaltInvocationError('"disk_format" needs to be one ' + |
| 205 | 'of the following: {0}'.format( |
| 206 | ', '.join(df_list))) |
| 207 | if tags: |
| 208 | if not isinstance(tags, list): |
| 209 | raise SaltInvocationError('Incorrect input type for the {0} ' |
| 210 | 'parameter: expected: {1}, ' |
| 211 | 'got {2}'.format("tags", list, |
| 212 | type(tags))) |
| 213 | |
| 214 | |
| 215 | def _validate_task_params(task_type, input_params): |
| 216 | # Only import tasks are currently supported |
| 217 | # TODO(eezhova): Add support for "export" and "clone" task types |
| 218 | valid_task_types = ["import", ] |
| 219 | |
| 220 | import_required_params = {"import_from", "import_from_format", |
| 221 | "image_properties"} |
| 222 | |
| 223 | if task_type not in valid_task_types: |
| 224 | raise SaltInvocationError("'task_type' must be one of the following: " |
| 225 | "{0}".format(', '.join(valid_task_types))) |
| 226 | |
| 227 | if task_type == "import": |
| 228 | valid_import_from_formats = ['ami', 'ari', 'aki', 'vhd', 'vmdk', |
| 229 | 'raw', 'qcow2', 'vdi', 'iso'] |
| 230 | missing_params = import_required_params - set(input_params.keys()) |
| 231 | if missing_params: |
| 232 | raise SaltInvocationError( |
| 233 | "Missing the following task parameters for the 'import' task: " |
| 234 | "{0}".format(', '.join(missing_params))) |
| 235 | |
| 236 | import_from = input_params['import_from'] |
| 237 | import_from_format = input_params['import_from_format'] |
| 238 | image_properties = input_params['image_properties'] |
| 239 | if not import_from.startswith(('http://', 'https://')): |
| 240 | raise SaltInvocationError("Only non-local sources of image data " |
| 241 | "are supported.") |
| 242 | if import_from_format not in valid_import_from_formats: |
| 243 | raise SaltInvocationError( |
| 244 | "'import_from_format' needs to be one of the following: " |
| 245 | "{0}".format(', '.join(valid_import_from_formats))) |
| 246 | _validate_image_params(**image_properties) |
| 247 | |
| 248 | |
| 249 | def task_create(task_type, profile=None, input_params=None): |
| 250 | """ |
| 251 | Create a Glance V2 task of a given type |
| 252 | |
| 253 | :param task_type: Task type |
| 254 | :param profile: Authentication profile |
| 255 | :param input_params: Dictionary with input parameters for a task |
| 256 | :return: Dictionary with created task's parameters |
| 257 | """ |
| 258 | g_client = _auth(profile, api_version=2) |
| 259 | log.debug( |
| 260 | 'Task type: {}\nInput params: {}'.format(task_type, input_params) |
| 261 | ) |
| 262 | task = g_client.tasks.create(type=task_type, input=input_params) |
| 263 | log.debug("Created task: {}".format(dict(task))) |
| 264 | created_task = task_show(task.id, profile=profile) |
| 265 | return created_task |
| 266 | |
| 267 | |
| 268 | def task_show(task_id, profile=None): |
| 269 | """ |
| 270 | Show a Glance V2 task |
| 271 | |
| 272 | :param task_id: ID of a task to show |
| 273 | :param profile: Authentication profile |
| 274 | :return: Dictionary with created task's parameters |
| 275 | """ |
| 276 | g_client = _auth(profile) |
| 277 | ret = {} |
| 278 | try: |
| 279 | task = g_client.tasks.get(task_id) |
| 280 | except exc.HTTPNotFound: |
| 281 | return { |
| 282 | 'result': False, |
| 283 | 'comment': 'No task with ID {0}'.format(task_id) |
| 284 | } |
| 285 | pformat = pprint.PrettyPrinter(indent=4).pformat |
| 286 | log.debug('Properties of task {0}:\n{1}'.format( |
| 287 | task_id, pformat(task))) |
| 288 | |
| 289 | schema = image_schema(schema_type='task', profile=profile) |
| 290 | if len(schema.keys()) == 1: |
| 291 | schema = schema['task'] |
| 292 | for key in schema.keys(): |
| 293 | if key in task: |
| 294 | ret[key] = task[key] |
| 295 | return ret |
| 296 | |
| 297 | |
| 298 | def task_list(profile=None): |
| 299 | """ |
| 300 | List Glance V2 tasks |
| 301 | |
| 302 | :param profile: Authentication profile |
| 303 | :return: Dictionary with existing tasks |
| 304 | """ |
| 305 | g_client = _auth(profile) |
| 306 | ret = {} |
| 307 | tasks = g_client.tasks.list() |
| 308 | schema = image_schema(schema_type='task', profile=profile) |
| 309 | if len(schema.keys()) == 1: |
| 310 | schema = schema['task'] |
| 311 | for task in tasks: |
| 312 | task_dict = {} |
| 313 | for key in schema.keys(): |
| 314 | if key in task: |
| 315 | task_dict[key] = task[key] |
| 316 | ret[task['id']] = task_dict |
| 317 | return ret |
| 318 | |
| 319 | |
| 320 | def get_image_owner_id(name, profile=None): |
| 321 | """ |
| 322 | Mine function to get image owner |
| 323 | |
| 324 | :param name: Name of the image |
| 325 | :param profile: Authentication profile |
| 326 | :return: Image owner ID or [] if image is not found |
| 327 | """ |
| 328 | g_client = _auth(profile) |
| 329 | image_id = None |
| 330 | for image in g_client.images.list(): |
| 331 | if image.name == name: |
| 332 | image_id = image.id |
| 333 | continue |
| 334 | if not image_id: |
| 335 | return [] |
| 336 | try: |
| 337 | image = g_client.images.get(image_id) |
| 338 | except exc.HTTPNotFound: |
| 339 | return [] |
| 340 | return image['owner'] |
| 341 | |
| 342 | |
| 343 | def image_schema(schema_type='image', profile=None): |
| 344 | ''' |
| 345 | Returns names and descriptions of the schema "image"'s |
| 346 | properties for this profile's instance of glance |
| 347 | |
| 348 | CLI Example: |
| 349 | |
| 350 | .. code-block:: bash |
| 351 | |
| 352 | salt '*' glance.image_schema |
| 353 | ''' |
| 354 | return schema_get(schema_type, profile) |
| 355 | |
| 356 | |
| 357 | def schema_get(name, profile=None): |
| 358 | ''' |
| 359 | Known valid names of schemas are: |
| 360 | - image |
| 361 | - images |
| 362 | - member |
| 363 | - members |
| 364 | |
| 365 | CLI Example: |
| 366 | |
| 367 | .. code-block:: bash |
| 368 | |
| 369 | salt '*' glance.schema_get name=f16-jeos |
| 370 | ''' |
| 371 | g_client = _auth(profile) |
| 372 | pformat = pprint.PrettyPrinter(indent=4).pformat |
| 373 | schema_props = {} |
| 374 | for prop in g_client.schemas.get(name).properties: |
| 375 | schema_props[prop.name] = prop.description |
| 376 | log.debug('Properties of schema {0}:\n{1}'.format( |
| 377 | name, pformat(schema_props))) |
| 378 | return {name: schema_props} |