blob: d607fef41acfc96bb9fdece50f52f4356c3d7bca [file] [log] [blame]
Elena Ezhova1c2ebae2017-07-03 18:29:05 +04001# -*- coding: utf-8 -*-
2"""
3Module extending the salt.modules.glance modules.
4
5This module adds functionality for managing Glance V2 tasks by exposing the
6following 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
45from __future__ import absolute_import
46import logging
47import pprint
48import re
49
50# Import salt libs
51from salt.exceptions import SaltInvocationError
52
53from salt.version import (
54 __version__,
55 SaltStackVersion
56 )
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +030057
58from salt.utils import warn_until
59
Elena Ezhova1c2ebae2017-07-03 18:29:05 +040060# pylint: disable=import-error
61HAS_GLANCE = False
62try:
63 from glanceclient import client
64 from glanceclient import exc
65 HAS_GLANCE = True
66except ImportError:
67 pass
68
Elena Ezhova1c2ebae2017-07-03 18:29:05 +040069
70logging.basicConfig(level=logging.DEBUG)
71log = logging.getLogger(__name__)
72
73
74def __virtual__():
75 '''
76 Only load this module if glance
77 is installed on this minion.
78 '''
79 if not HAS_GLANCE:
80 return False, ("The glance execution module cannot be loaded: "
81 "the glanceclient python library is not available.")
Elena Ezhova1c2ebae2017-07-03 18:29:05 +040082 return True
83
84
85__opts__ = {}
86
87
88def _auth(profile=None, api_version=2, **connection_args):
89 '''
90 Set up glance credentials, returns
91 `glanceclient.client.Client`. Optional parameter
92 "api_version" defaults to 2.
93
94 Only intended to be used within glance-enabled modules
95 '''
96
Oleksii Molchanov74048b62020-05-25 16:59:07 +030097 endpoint_type = str(connection_args.get('connection_endpoint_type',
98 'internal'))
Oleg Iurchenko4afbbbb2018-02-20 15:50:47 +020099 kstone = __salt__['keystoneng.auth'](profile, **connection_args)
Oleksii Molchanov74048b62020-05-25 16:59:07 +0300100 g_endpoint = __salt__['keystoneng.endpoint_get']('glance',
101 profile=profile,
102 interface=endpoint_type)
Oleg Iurchenko4afbbbb2018-02-20 15:50:47 +0200103 glance_client = client.Client(api_version, session=kstone.session, endpoint=g_endpoint.get('url'))
104 return glance_client
Elena Ezhova1c2ebae2017-07-03 18:29:05 +0400105
106
107def _validate_image_params(visibility=None, container_format='bare',
108 disk_format='raw', tags=None, **kwargs):
109 # valid options for "visibility":
110 v_list = ['public', 'private', 'shared', 'community']
111 # valid options for "container_format":
112 cf_list = ['ami', 'ari', 'aki', 'bare', 'ovf']
113 # valid options for "disk_format":
114 df_list = ['ami', 'ari', 'aki', 'vhd', 'vmdk',
115 'raw', 'qcow2', 'vdi', 'iso']
116
117 if visibility is not None:
118 if visibility not in v_list:
119 raise SaltInvocationError('"visibility" needs to be one ' +
120 'of the following: {0}'.format(
121 ', '.join(v_list)))
122 if container_format not in cf_list:
123 raise SaltInvocationError('"container_format" needs to be ' +
124 'one of the following: {0}'.format(
125 ', '.join(cf_list)))
126 if disk_format not in df_list:
127 raise SaltInvocationError('"disk_format" needs to be one ' +
128 'of the following: {0}'.format(
129 ', '.join(df_list)))
130 if tags:
131 if not isinstance(tags, list):
132 raise SaltInvocationError('Incorrect input type for the {0} '
133 'parameter: expected: {1}, '
134 'got {2}'.format("tags", list,
135 type(tags)))
136
137
138def _validate_task_params(task_type, input_params):
139 # Only import tasks are currently supported
140 # TODO(eezhova): Add support for "export" and "clone" task types
141 valid_task_types = ["import", ]
142
143 import_required_params = {"import_from", "import_from_format",
144 "image_properties"}
145
146 if task_type not in valid_task_types:
147 raise SaltInvocationError("'task_type' must be one of the following: "
148 "{0}".format(', '.join(valid_task_types)))
149
150 if task_type == "import":
151 valid_import_from_formats = ['ami', 'ari', 'aki', 'vhd', 'vmdk',
152 'raw', 'qcow2', 'vdi', 'iso']
153 missing_params = import_required_params - set(input_params.keys())
154 if missing_params:
155 raise SaltInvocationError(
156 "Missing the following task parameters for the 'import' task: "
157 "{0}".format(', '.join(missing_params)))
158
159 import_from = input_params['import_from']
160 import_from_format = input_params['import_from_format']
161 image_properties = input_params['image_properties']
162 if not import_from.startswith(('http://', 'https://')):
163 raise SaltInvocationError("Only non-local sources of image data "
164 "are supported.")
165 if import_from_format not in valid_import_from_formats:
166 raise SaltInvocationError(
167 "'import_from_format' needs to be one of the following: "
168 "{0}".format(', '.join(valid_import_from_formats)))
169 _validate_image_params(**image_properties)
170
171
172def task_create(task_type, profile=None, input_params=None):
173 """
174 Create a Glance V2 task of a given type
175
176 :param task_type: Task type
177 :param profile: Authentication profile
178 :param input_params: Dictionary with input parameters for a task
179 :return: Dictionary with created task's parameters
180 """
181 g_client = _auth(profile, api_version=2)
182 log.debug(
183 'Task type: {}\nInput params: {}'.format(task_type, input_params)
184 )
185 task = g_client.tasks.create(type=task_type, input=input_params)
186 log.debug("Created task: {}".format(dict(task)))
187 created_task = task_show(task.id, profile=profile)
188 return created_task
189
190
191def task_show(task_id, profile=None):
192 """
193 Show a Glance V2 task
194
195 :param task_id: ID of a task to show
196 :param profile: Authentication profile
197 :return: Dictionary with created task's parameters
198 """
199 g_client = _auth(profile)
200 ret = {}
201 try:
202 task = g_client.tasks.get(task_id)
203 except exc.HTTPNotFound:
204 return {
205 'result': False,
206 'comment': 'No task with ID {0}'.format(task_id)
207 }
208 pformat = pprint.PrettyPrinter(indent=4).pformat
209 log.debug('Properties of task {0}:\n{1}'.format(
210 task_id, pformat(task)))
211
212 schema = image_schema(schema_type='task', profile=profile)
213 if len(schema.keys()) == 1:
214 schema = schema['task']
215 for key in schema.keys():
216 if key in task:
217 ret[key] = task[key]
218 return ret
219
220
221def task_list(profile=None):
222 """
223 List Glance V2 tasks
224
225 :param profile: Authentication profile
226 :return: Dictionary with existing tasks
227 """
228 g_client = _auth(profile)
229 ret = {}
230 tasks = g_client.tasks.list()
231 schema = image_schema(schema_type='task', profile=profile)
232 if len(schema.keys()) == 1:
233 schema = schema['task']
234 for task in tasks:
235 task_dict = {}
236 for key in schema.keys():
237 if key in task:
238 task_dict[key] = task[key]
239 ret[task['id']] = task_dict
240 return ret
241
242
243def get_image_owner_id(name, profile=None):
244 """
245 Mine function to get image owner
246
247 :param name: Name of the image
248 :param profile: Authentication profile
249 :return: Image owner ID or [] if image is not found
250 """
251 g_client = _auth(profile)
252 image_id = None
253 for image in g_client.images.list():
254 if image.name == name:
255 image_id = image.id
256 continue
257 if not image_id:
258 return []
259 try:
260 image = g_client.images.get(image_id)
261 except exc.HTTPNotFound:
262 return []
263 return image['owner']
264
265
266def image_schema(schema_type='image', profile=None):
267 '''
268 Returns names and descriptions of the schema "image"'s
269 properties for this profile's instance of glance
270
271 CLI Example:
272
273 .. code-block:: bash
274
275 salt '*' glance.image_schema
276 '''
277 return schema_get(schema_type, profile)
278
279
280def schema_get(name, profile=None):
281 '''
282 Known valid names of schemas are:
283 - image
284 - images
285 - member
286 - members
287
288 CLI Example:
289
290 .. code-block:: bash
291
292 salt '*' glance.schema_get name=f16-jeos
293 '''
294 g_client = _auth(profile)
295 pformat = pprint.PrettyPrinter(indent=4).pformat
296 schema_props = {}
297 for prop in g_client.schemas.get(name).properties:
298 schema_props[prop.name] = prop.description
299 log.debug('Properties of schema {0}:\n{1}'.format(
300 name, pformat(schema_props)))
301 return {name: schema_props}
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +0300302
303def image_list(id=None, profile=None, name=None): # pylint: disable=C0103
304 '''
305 Return a list of available images (glance image-list)
306
307 CLI Example:
308
309 .. code-block:: bash
310
311 salt '*' glance.image_list
312 '''
Martin Polreichaeb1ea62018-06-04 16:13:03 +0200313
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +0300314 g_client = _auth(profile)
Martin Polreichaeb1ea62018-06-04 16:13:03 +0200315 ret = []
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +0300316 for image in g_client.images.list():
317 if id is None and name is None:
318 _add_image(ret, image)
319 else:
320 if id is not None and id == image.id:
321 _add_image(ret, image)
322 return ret
323 if name == image.name:
Martin Polreichaeb1ea62018-06-04 16:13:03 +0200324 if name in ret:
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +0300325 # Not really worth an exception
326 return {
327 'result': False,
328 'comment':
329 'More than one image with '
330 'name "{0}"'.format(name)
331 }
332 _add_image(ret, image)
333 log.debug('Returning images: {0}'.format(ret))
334 return ret
335
336def _add_image(collection, image):
337 '''
338 Add image to given dictionary
339 '''
340 image_prep = {
341 'id': image.id,
342 'name': image.name,
343 'created_at': image.created_at,
344 'file': image.file,
345 'min_disk': image.min_disk,
346 'min_ram': image.min_ram,
347 'owner': image.owner,
348 'protected': image.protected,
349 'status': image.status,
350 'tags': image.tags,
351 'updated_at': image.updated_at,
352 'visibility': image.visibility,
353 }
354 # Those cause AttributeErrors in Icehouse' glanceclient
355 for attr in ['container_format', 'disk_format', 'size']:
356 if attr in image:
357 image_prep[attr] = image[attr]
358 if type(collection) is dict:
359 collection[image.name] = image_prep
360 elif type(collection) is list:
361 collection.append(image_prep)
362 else:
363 msg = '"collection" is {0}'.format(type(collection)) +\
364 'instead of dict or list.'
365 log.error(msg)
366 raise TypeError(msg)
367 return collection
368
369def image_create(name, location=None, profile=None, visibility=None,
370 container_format='bare', disk_format='raw', protected=None,
371 copy_from=None, is_public=None):
372 '''
373 Create an image (glance image-create)
374
375 CLI Example, old format:
376
377 .. code-block:: bash
378
379 salt '*' glance.image_create name=f16-jeos is_public=true \\
380 disk_format=qcow2 container_format=ovf \\
381 copy_from=http://berrange.fedorapeople.org/\
382 images/2012-02-29/f16-x86_64-openstack-sda.qcow2
383
384 CLI Example, new format resembling Glance API v2:
385
386 .. code-block:: bash
387
388 salt '*' glance.image_create name=f16-jeos visibility=public \\
389 disk_format=qcow2 container_format=ovf \\
390 copy_from=http://berrange.fedorapeople.org/\
391 images/2012-02-29/f16-x86_64-openstack-sda.qcow2
392
393 The parameter 'visibility' defaults to 'public' if neither
394 'visibility' nor 'is_public' is specified.
395 '''
396 kwargs = {}
397 # valid options for "visibility":
398 v_list = ['public', 'private']
399 # valid options for "container_format":
400 cf_list = ['ami', 'ari', 'aki', 'bare', 'ovf']
401 # valid options for "disk_format":
402 df_list = ['ami', 'ari', 'aki', 'vhd', 'vmdk',
403 'raw', 'qcow2', 'vdi', 'iso']
404 # 'location' and 'visibility' are the parameters used in
405 # Glance API v2. For now we have to use v1 for now (see below)
406 # but this modules interface will change in Carbon.
407 if copy_from is not None or is_public is not None:
408 warn_until('Carbon', 'The parameters \'copy_from\' and '
409 '\'is_public\' are deprecated and will be removed. '
410 'Use \'location\' and \'visibility\' instead.')
411 if is_public is not None and visibility is not None:
412 raise SaltInvocationError('Must only specify one of '
413 '\'is_public\' and \'visibility\'')
414 if copy_from is not None and location is not None:
415 raise SaltInvocationError('Must only specify one of '
416 '\'copy_from\' and \'location\'')
417 if copy_from is not None:
418 kwargs['copy_from'] = copy_from
419 else:
420 kwargs['copy_from'] = location
421 if is_public is not None:
422 kwargs['is_public'] = is_public
423 elif visibility is not None:
424 if visibility not in v_list:
425 raise SaltInvocationError('"visibility" needs to be one ' +
426 'of the following: {0}'.format(', '.join(v_list)))
427 elif visibility == 'public':
428 kwargs['is_public'] = True
429 else:
430 kwargs['is_public'] = False
431 else:
432 kwargs['is_public'] = True
433 if container_format not in cf_list:
434 raise SaltInvocationError('"container_format" needs to be ' +
435 'one of the following: {0}'.format(', '.join(cf_list)))
436 else:
437 kwargs['container_format'] = container_format
438 if disk_format not in df_list:
439 raise SaltInvocationError('"disk_format" needs to be one ' +
440 'of the following: {0}'.format(', '.join(df_list)))
441 else:
442 kwargs['disk_format'] = disk_format
443 if protected is not None:
444 kwargs['protected'] = protected
445 # Icehouse's glanceclient doesn't have add_location() and
446 # glanceclient.v2 doesn't implement Client.images.create()
447 # in a usable fashion. Thus we have to use v1 for now.
448 g_client = _auth(profile, api_version=1)
449 image = g_client.images.create(name=name, **kwargs)
450 return image_show(image.id, profile=profile)
451
452def image_delete(id=None, name=None, profile=None): # pylint: disable=C0103
453 '''
454 Delete an image (glance image-delete)
455
456 CLI Examples:
457
458 .. code-block:: bash
459
460 salt '*' glance.image_delete c2eb2eb0-53e1-4a80-b990-8ec887eae7df
461 salt '*' glance.image_delete id=c2eb2eb0-53e1-4a80-b990-8ec887eae7df
462 salt '*' glance.image_delete name=f16-jeos
463 '''
464 g_client = _auth(profile)
465 image = {'id': False, 'name': None}
466 if name:
467 for image in g_client.images.list():
468 if image.name == name:
469 id = image.id # pylint: disable=C0103
470 continue
471 if not id:
472 return {
473 'result': False,
474 'comment':
475 'Unable to resolve image id '
476 'for name {0}'.format(name)
477 }
478 elif not name:
479 name = image['name']
480 try:
481 g_client.images.delete(id)
482 except exc.HTTPNotFound:
483 return {
484 'result': False,
485 'comment': 'No image with ID {0}'.format(id)
486 }
487 except exc.HTTPForbidden as forbidden:
488 log.error(str(forbidden))
489 return {
490 'result': False,
491 'comment': str(forbidden)
492 }
493 return {
494 'result': True,
495 'comment': 'Deleted image \'{0}\' ({1}).'.format(name, id),
496 }
497
498def image_show(id=None, name=None, profile=None): # pylint: disable=C0103
499 '''
500 Return details about a specific image (glance image-show)
501
502 CLI Example:
503
504 .. code-block:: bash
505
506 salt '*' glance.image_show
507 '''
508 g_client = _auth(profile)
509 ret = {}
510 if name:
511 for image in g_client.images.list():
512 if image.name == name:
513 id = image.id # pylint: disable=C0103
514 continue
515 if not id:
516 return {
517 'result': False,
518 'comment':
519 'Unable to resolve image ID '
520 'for name \'{0}\''.format(name)
521 }
522 try:
523 image = g_client.images.get(id)
524 except exc.HTTPNotFound:
525 return {
526 'result': False,
527 'comment': 'No image with ID {0}'.format(id)
528 }
529 pformat = pprint.PrettyPrinter(indent=4).pformat
530 log.debug('Properties of image {0}:\n{1}'.format(
531 image.name, pformat(image)))
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +0300532 schema = image_schema(profile=profile)
533 if len(schema.keys()) == 1:
534 schema = schema['image']
535 for key in schema.keys():
536 if key in image:
Martin Polreichaeb1ea62018-06-04 16:13:03 +0200537 ret[key] = image[key]
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +0300538 return ret
539
540def image_update(id=None, name=None, profile=None, **kwargs): # pylint: disable=C0103
541 '''
542 Update properties of given image.
543 Known to work for:
544 - min_ram (in MB)
545 - protected (bool)
546 - visibility ('public' or 'private')
547
548 CLI Example:
549
550 .. code-block:: bash
551
552 salt '*' glance.image_update id=c2eb2eb0-53e1-4a80-b990-8ec887eae7df
553 salt '*' glance.image_update name=f16-jeos
554 '''
555 if id:
556 image = image_show(id=id, profile=profile)
557 if 'result' in image and not image['result']:
558 return image
559 elif len(image) == 1:
560 image = image.values()[0]
561 elif name:
562 img_list = image_list(name=name, profile=profile)
563 if img_list is dict and 'result' in img_list:
564 return img_list
565 elif len(img_list) == 0:
566 return {
567 'result': False,
568 'comment':
569 'No image with name \'{0}\' '
570 'found.'.format(name)
571 }
572 elif len(img_list) == 1:
573 try:
574 image = img_list[0]
575 except KeyError:
576 image = img_list[name]
577 else:
578 raise SaltInvocationError
579 log.debug('Found image:\n{0}'.format(image))
580 to_update = {}
581 for key, value in kwargs.items():
582 if key.startswith('_'):
583 continue
584 if key not in image or image[key] != value:
585 log.debug('add <{0}={1}> to to_update'.format(key, value))
586 to_update[key] = value
587 g_client = _auth(profile)
588 updated = g_client.images.update(image['id'], **to_update)
Oleh Hryhorov6c21bbb2018-03-28 17:06:09 +0300589 return updated
590
591def _item_list(profile=None):
592 '''
593 Template for writing list functions
594 Return a list of available items (glance items-list)
595
596 CLI Example:
597
598 .. code-block:: bash
599
600 salt '*' glance.item_list
601 '''
602 g_client = _auth(profile)
603 ret = []
604 for item in g_client.items.list():
605 ret.append(item.__dict__)
606 #ret[item.name] = {
607 # 'name': item.name,
608 # }
609 return ret