1#!/usr/bin/python
2#
3# (c) 2018, Evert Mulder <evertmulder@gmail.com> (base on manageiq_user.py by Daniel Korn <korndaniel1@gmail.com>)
4#
5# This file is part of Ansible
6#
7# Ansible is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# Ansible is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
19
20from __future__ import (absolute_import, division, print_function)
21
22__metaclass__ = type
23
24ANSIBLE_METADATA = {'metadata_version': '1.1',
25                    'status': ['preview'],
26                    'supported_by': 'community'}
27
28DOCUMENTATION = '''
29
30module: manageiq_group
31
32short_description: Management of groups in ManageIQ.
33extends_documentation_fragment: manageiq
34version_added: '2.8'
35author: Evert Mulder (@evertmulder)
36description:
37  - The manageiq_group module supports adding, updating and deleting groups in ManageIQ.
38requirements:
39- manageiq-client
40
41options:
42  state:
43    description:
44    - absent - group should not exist, present - group should be.
45    choices: ['absent', 'present']
46    default: 'present'
47  description:
48    description:
49    - The group description.
50    required: true
51    default: null
52  role_id:
53    description:
54    - The the group role id
55    required: false
56    default: null
57  role:
58    description:
59    - The the group role name
60    - The C(role_id) has precedence over the C(role) when supplied.
61    required: false
62    default: null
63  tenant_id:
64    description:
65    - The tenant for the group identified by the tenant id.
66    required: false
67    default: null
68  tenant:
69    description:
70    - The tenant for the group identified by the tenant name.
71    - The C(tenant_id) has precedence over the C(tenant) when supplied.
72    - Tenant names are case sensitive.
73    required: false
74    default: null
75  managed_filters:
76    description: The tag values per category
77    type: dict
78    required: false
79    default: null
80  managed_filters_merge_mode:
81    description:
82    - In merge mode existing categories are kept or updated, new categories are added.
83    - In replace mode all categories will be replaced with the supplied C(managed_filters).
84    choices: [ merge, replace ]
85    default: replace
86  belongsto_filters:
87    description: A list of strings with a reference to the allowed host, cluster or folder
88    type: list
89    required: false
90    default: null
91  belongsto_filters_merge_mode:
92    description:
93    - In merge mode existing settings are merged with the supplied C(belongsto_filters).
94    - In replace mode current values are replaced with the supplied C(belongsto_filters).
95    choices: [ merge, replace ]
96    default: replace
97'''
98
99EXAMPLES = '''
100- name: Create a group in ManageIQ with the role EvmRole-user and tenant 'my_tenant'
101  manageiq_group:
102    description: 'MyGroup-user'
103    role: 'EvmRole-user'
104    tenant: 'my_tenant'
105    manageiq_connection:
106      url: 'https://manageiq_server'
107      username: 'admin'
108      password: 'smartvm'
109      validate_certs: False
110
111- name: Create a group in ManageIQ with the role EvmRole-user and tenant with tenant_id 4
112  manageiq_group:
113    description: 'MyGroup-user'
114    role: 'EvmRole-user'
115    tenant_id: 4
116    manageiq_connection:
117      url: 'https://manageiq_server'
118      username: 'admin'
119      password: 'smartvm'
120      validate_certs: False
121
122- name:
123  - Create or update a group in ManageIQ with the role EvmRole-user and tenant my_tenant.
124  - Apply 3 prov_max_cpu and 2 department tags to the group.
125  - Limit access to a cluster for the group.
126  manageiq_group:
127    description: 'MyGroup-user'
128    role: 'EvmRole-user'
129    tenant: my_tenant
130    managed_filters:
131      prov_max_cpu:
132      - '1'
133      - '2'
134      - '4'
135      department:
136      - defense
137      - engineering
138    managed_filters_merge_mode: replace
139    belongsto_filters:
140    - "/belongsto/ExtManagementSystem|ProviderName/EmsFolder|Datacenters/EmsFolder|dc_name/EmsFolder|host/EmsCluster|Cluster name"
141    belongsto_filters_merge_mode: merge
142    manageiq_connection:
143      url: 'https://manageiq_server'
144      username: 'admin'
145      password: 'smartvm'
146      validate_certs: False
147
148- name: Delete a group in ManageIQ
149  manageiq_group:
150    state: 'absent'
151    description: 'MyGroup-user'
152    manageiq_connection:
153      url: 'http://127.0.0.1:3000'
154      username: 'admin'
155      password: 'smartvm'
156
157- name: Delete a group in ManageIQ using a token
158  manageiq_group:
159    state: 'absent'
160    description: 'MyGroup-user'
161    manageiq_connection:
162      url: 'http://127.0.0.1:3000'
163      token: 'sometoken'
164'''
165
166RETURN = '''
167group:
168  description: The group.
169  returned: success
170  type: complex
171  contains:
172    description:
173      description: The group description
174      returned: success
175      type: str
176    id:
177      description: The group id
178      returned: success
179      type: int
180    group_type:
181      description: The group type, system or user
182      returned: success
183      type: str
184    role:
185      description: The group role name
186      returned: success
187      type: str
188    tenant:
189      description: The group tenant name
190      returned: success
191      type: str
192    managed_filters:
193      description: The tag values per category
194      returned: success
195      type: dict
196    belongsto_filters:
197      description: A list of strings with a reference to the allowed host, cluster or folder
198      returned: success
199      type: list
200    created_on:
201      description: Group creation date
202      returned: success
203      type: str
204      sample: "2018-08-12T08:37:55+00:00"
205    updated_on:
206      description: Group update date
207      returned: success
208      type: int
209      sample: "2018-08-12T08:37:55+00:00"
210'''
211
212from ansible.module_utils.basic import AnsibleModule
213from ansible.module_utils.manageiq import ManageIQ, manageiq_argument_spec
214
215
216class ManageIQgroup(object):
217    """
218        Object to execute group management operations in manageiq.
219    """
220
221    def __init__(self, manageiq):
222        self.manageiq = manageiq
223
224        self.module = self.manageiq.module
225        self.api_url = self.manageiq.api_url
226        self.client = self.manageiq.client
227
228    def group(self, description):
229        """ Search for group object by description.
230        Returns:
231            the group, or None if group was not found.
232        """
233        groups = self.client.collections.groups.find_by(description=description)
234        if len(groups) == 0:
235            return None
236        else:
237            return groups[0]
238
239    def tenant(self, tenant_id, tenant_name):
240        """ Search for tenant entity by name or id
241        Returns:
242            the tenant entity, None if no id or name was supplied
243        """
244
245        if tenant_id:
246            tenant = self.client.get_entity('tenants', tenant_id)
247            if not tenant:
248                self.module.fail_json(msg="Tenant with id '%s' not found in manageiq" % str(tenant_id))
249            return tenant
250        else:
251            if tenant_name:
252                tenant_res = self.client.collections.tenants.find_by(name=tenant_name)
253                if not tenant_res:
254                    self.module.fail_json(msg="Tenant '%s' not found in manageiq" % tenant_name)
255                if len(tenant_res) > 1:
256                    self.module.fail_json(msg="Multiple tenants found in manageiq with name '%s" % tenant_name)
257                tenant = tenant_res[0]
258                return tenant
259            else:
260                # No tenant name or tenant id supplied
261                return None
262
263    def role(self, role_id, role_name):
264        """ Search for a role object by name or id.
265        Returns:
266            the role entity, None no id or name was supplied
267
268            the role, or send a module Fail signal if role not found.
269        """
270        if role_id:
271            role = self.client.get_entity('roles', role_id)
272            if not role:
273                self.module.fail_json(msg="Role with id '%s' not found in manageiq" % str(role_id))
274            return role
275        else:
276            if role_name:
277                role_res = self.client.collections.roles.find_by(name=role_name)
278                if not role_res:
279                    self.module.fail_json(msg="Role '%s' not found in manageiq" % role_name)
280                if len(role_res) > 1:
281                    self.module.fail_json(msg="Multiple roles found in manageiq with name '%s" % role_name)
282                return role_res[0]
283            else:
284                # No role name or role id supplied
285                return None
286
287    @staticmethod
288    def merge_dict_values(norm_current_values, norm_updated_values):
289        """ Create an merged update object for manageiq group filters.
290
291            The input dict contain the tag values per category.
292            If the new values contain the category, all tags for that category are replaced
293            If the new values do not contain the category, the existing tags are kept
294
295        Returns:
296            the nested array with the merged values, used in the update post body
297        """
298
299        # If no updated values are supplied, in merge mode, the original values must be returned
300        # otherwise the existing tag filters will be removed.
301        if norm_current_values and (not norm_updated_values):
302            return norm_current_values
303
304        # If no existing tag filters exist, use the user supplied values
305        if (not norm_current_values) and norm_updated_values:
306            return norm_updated_values
307
308        # start with norm_current_values's keys and values
309        res = norm_current_values.copy()
310        # replace res with norm_updated_values's keys and values
311        res.update(norm_updated_values)
312        return res
313
314    def delete_group(self, group):
315        """ Deletes a group from manageiq.
316
317        Returns:
318            a dict of:
319            changed: boolean indicating if the entity was updated.
320            msg: a short message describing the operation executed.
321        """
322        try:
323            url = '%s/groups/%s' % (self.api_url, group['id'])
324            result = self.client.post(url, action='delete')
325        except Exception as e:
326            self.module.fail_json(msg="failed to delete group %s: %s" % (group['description'], str(e)))
327
328        if result['success'] is False:
329            self.module.fail_json(msg=result['message'])
330
331        return dict(
332            changed=True,
333            msg="deleted group %s with id %i" % (group['description'], group['id']))
334
335    def edit_group(self, group, description, role, tenant, norm_managed_filters, managed_filters_merge_mode,
336                   belongsto_filters, belongsto_filters_merge_mode):
337        """ Edit a manageiq group.
338
339        Returns:
340            a dict of:
341            changed: boolean indicating if the entity was updated.
342            msg: a short message describing the operation executed.
343        """
344
345        if role or norm_managed_filters or belongsto_filters:
346            group.reload(attributes=['miq_user_role_name', 'entitlement'])
347
348        try:
349            current_role = group['miq_user_role_name']
350        except AttributeError:
351            current_role = None
352
353        changed = False
354        resource = {}
355
356        if description and group['description'] != description:
357            resource['description'] = description
358            changed = True
359
360        if tenant and group['tenant_id'] != tenant['id']:
361            resource['tenant'] = dict(id=tenant['id'])
362            changed = True
363
364        if role and current_role != role['name']:
365            resource['role'] = dict(id=role['id'])
366            changed = True
367
368        if norm_managed_filters or belongsto_filters:
369
370            # Only compare if filters are supplied
371            entitlement = group['entitlement']
372
373            if 'filters' not in entitlement:
374                # No existing filters exist, use supplied filters
375                managed_tag_filters_post_body = self.normalized_managed_tag_filters_to_miq(norm_managed_filters)
376                resource['filters'] = {'managed': managed_tag_filters_post_body, "belongsto": belongsto_filters}
377                changed = True
378            else:
379                current_filters = entitlement['filters']
380                new_filters = self.edit_group_edit_filters(current_filters,
381                                                           norm_managed_filters, managed_filters_merge_mode,
382                                                           belongsto_filters, belongsto_filters_merge_mode)
383                if new_filters:
384                    resource['filters'] = new_filters
385                    changed = True
386
387        if not changed:
388            return dict(
389                changed=False,
390                msg="group %s is not changed." % group['description'])
391
392        # try to update group
393        try:
394            self.client.post(group['href'], action='edit', resource=resource)
395            changed = True
396        except Exception as e:
397            self.module.fail_json(msg="failed to update group %s: %s" % (group['name'], str(e)))
398
399        return dict(
400            changed=changed,
401            msg="successfully updated the group %s with id %s" % (group['description'], group['id']))
402
403    def edit_group_edit_filters(self, current_filters, norm_managed_filters, managed_filters_merge_mode,
404                                belongsto_filters, belongsto_filters_merge_mode):
405        """ Edit a manageiq group filters.
406
407        Returns:
408            None if no the group was not updated
409            If the group was updated the post body part for updating the group
410        """
411        filters_updated = False
412        new_filters_resource = {}
413
414        # Process belongsto filters
415        if 'belongsto' in current_filters:
416            current_belongsto_set = set(current_filters['belongsto'])
417        else:
418            current_belongsto_set = set()
419
420        if belongsto_filters:
421            new_belongsto_set = set(belongsto_filters)
422        else:
423            new_belongsto_set = set()
424
425        if current_belongsto_set == new_belongsto_set:
426            new_filters_resource['belongsto'] = current_filters['belongsto']
427        else:
428            if belongsto_filters_merge_mode == 'merge':
429                current_belongsto_set.update(new_belongsto_set)
430                new_filters_resource['belongsto'] = list(current_belongsto_set)
431            else:
432                new_filters_resource['belongsto'] = list(new_belongsto_set)
433            filters_updated = True
434
435        # Process belongsto managed filter tags
436        # The input is in the form dict with keys are the categories and the tags are supplied string array
437        # ManageIQ, the current_managed, uses an array of arrays. One array of categories.
438        # We normalize the user input from a dict with arrays to a dict of sorted arrays
439        # We normalize the current manageiq array of arrays also to a dict of sorted arrays so we can compare
440        norm_current_filters = self.manageiq_filters_to_sorted_dict(current_filters)
441
442        if norm_current_filters == norm_managed_filters:
443            if 'managed' in current_filters:
444                new_filters_resource['managed'] = current_filters['managed']
445        else:
446            if managed_filters_merge_mode == 'merge':
447                merged_dict = self.merge_dict_values(norm_current_filters, norm_managed_filters)
448                new_filters_resource['managed'] = self.normalized_managed_tag_filters_to_miq(merged_dict)
449            else:
450                new_filters_resource['managed'] = self.normalized_managed_tag_filters_to_miq(norm_managed_filters)
451            filters_updated = True
452
453        if not filters_updated:
454            return None
455
456        return new_filters_resource
457
458    def create_group(self, description, role, tenant, norm_managed_filters, belongsto_filters):
459        """ Creates the group in manageiq.
460
461        Returns:
462            the created group id, name, created_on timestamp,
463            updated_on timestamp.
464        """
465        # check for required arguments
466        for key, value in dict(description=description).items():
467            if value in (None, ''):
468                self.module.fail_json(msg="missing required argument: %s" % key)
469
470        url = '%s/groups' % self.api_url
471
472        resource = {'description': description}
473
474        if role is not None:
475            resource['role'] = dict(id=role['id'])
476
477        if tenant is not None:
478            resource['tenant'] = dict(id=tenant['id'])
479
480        if norm_managed_filters or belongsto_filters:
481            managed_tag_filters_post_body = self.normalized_managed_tag_filters_to_miq(norm_managed_filters)
482            resource['filters'] = {'managed': managed_tag_filters_post_body, "belongsto": belongsto_filters}
483
484        try:
485            result = self.client.post(url, action='create', resource=resource)
486        except Exception as e:
487            self.module.fail_json(msg="failed to create group %s: %s" % (description, str(e)))
488
489        return dict(
490            changed=True,
491            msg="successfully created group %s" % description,
492            group_id=result['results'][0]['id']
493        )
494
495    @staticmethod
496    def normalized_managed_tag_filters_to_miq(norm_managed_filters):
497        if not norm_managed_filters:
498            return None
499
500        return list(norm_managed_filters.values())
501
502    @staticmethod
503    def manageiq_filters_to_sorted_dict(current_filters):
504        if 'managed' not in current_filters:
505            return None
506
507        res = {}
508        for tag_list in current_filters['managed']:
509            tag_list.sort()
510            key = tag_list[0].split('/')[2]
511            res[key] = tag_list
512
513        return res
514
515    @staticmethod
516    def normalize_user_managed_filters_to_sorted_dict(managed_filters, module):
517        if not managed_filters:
518            return None
519
520        res = {}
521        for cat_key in managed_filters:
522            cat_array = []
523            if not isinstance(managed_filters[cat_key], list):
524                module.fail_json(msg='Entry "{0}" of managed_filters must be a list!'.format(cat_key))
525            for tags in managed_filters[cat_key]:
526                miq_managed_tag = "/managed/" + cat_key + "/" + tags
527                cat_array.append(miq_managed_tag)
528            # Do not add empty categories. ManageIQ will remove all categories that are not supplied
529            if cat_array:
530                cat_array.sort()
531                res[cat_key] = cat_array
532        return res
533
534    @staticmethod
535    def create_result_group(group):
536        """ Creates the ansible result object from a manageiq group entity
537
538        Returns:
539            a dict with the group id, description, role, tenant, filters, group_type, created_on, updated_on
540        """
541        try:
542            role_name = group['miq_user_role_name']
543        except AttributeError:
544            role_name = None
545
546        managed_filters = None
547        belongsto_filters = None
548        if 'filters' in group['entitlement']:
549            filters = group['entitlement']['filters']
550            if 'belongsto' in filters:
551                belongsto_filters = filters['belongsto']
552            if 'managed' in filters:
553                managed_filters = {}
554                for tag_list in filters['managed']:
555                    key = tag_list[0].split('/')[2]
556                    tags = []
557                    for t in tag_list:
558                        tags.append(t.split('/')[3])
559                    managed_filters[key] = tags
560
561        return dict(
562            id=group['id'],
563            description=group['description'],
564            role=role_name,
565            tenant=group['tenant']['name'],
566            managed_filters=managed_filters,
567            belongsto_filters=belongsto_filters,
568            group_type=group['group_type'],
569            created_on=group['created_on'],
570            updated_on=group['updated_on'],
571        )
572
573
574def main():
575    argument_spec = dict(
576        description=dict(required=True, type='str'),
577        state=dict(choices=['absent', 'present'], default='present'),
578        role_id=dict(required=False, type='int'),
579        role=dict(required=False, type='str'),
580        tenant_id=dict(required=False, type='int'),
581        tenant=dict(required=False, type='str'),
582        managed_filters=dict(required=False, type='dict'),
583        managed_filters_merge_mode=dict(required=False, choices=['merge', 'replace'], default='replace'),
584        belongsto_filters=dict(required=False, type='list', elements='str'),
585        belongsto_filters_merge_mode=dict(required=False, choices=['merge', 'replace'], default='replace'),
586    )
587    # add the manageiq connection arguments to the arguments
588    argument_spec.update(manageiq_argument_spec())
589
590    module = AnsibleModule(
591        argument_spec=argument_spec
592    )
593
594    description = module.params['description']
595    state = module.params['state']
596    role_id = module.params['role_id']
597    role_name = module.params['role']
598    tenant_id = module.params['tenant_id']
599    tenant_name = module.params['tenant']
600    managed_filters = module.params['managed_filters']
601    managed_filters_merge_mode = module.params['managed_filters_merge_mode']
602    belongsto_filters = module.params['belongsto_filters']
603    belongsto_filters_merge_mode = module.params['belongsto_filters_merge_mode']
604
605    manageiq = ManageIQ(module)
606    manageiq_group = ManageIQgroup(manageiq)
607
608    group = manageiq_group.group(description)
609
610    # group should not exist
611    if state == "absent":
612        # if we have a group, delete it
613        if group:
614            res_args = manageiq_group.delete_group(group)
615        # if we do not have a group, nothing to do
616        else:
617            res_args = dict(
618                changed=False,
619                msg="group '%s' does not exist in manageiq" % description)
620
621    # group should exist
622    if state == "present":
623
624        tenant = manageiq_group.tenant(tenant_id, tenant_name)
625        role = manageiq_group.role(role_id, role_name)
626        norm_managed_filters = manageiq_group.normalize_user_managed_filters_to_sorted_dict(managed_filters, module)
627        # if we have a group, edit it
628        if group:
629            res_args = manageiq_group.edit_group(group, description, role, tenant,
630                                                 norm_managed_filters, managed_filters_merge_mode,
631                                                 belongsto_filters, belongsto_filters_merge_mode)
632
633        # if we do not have a group, create it
634        else:
635            res_args = manageiq_group.create_group(description, role, tenant, norm_managed_filters, belongsto_filters)
636            group = manageiq.client.get_entity('groups', res_args['group_id'])
637
638        group.reload(expand='resources', attributes=['miq_user_role_name', 'tenant', 'entitlement'])
639        res_args['group'] = manageiq_group.create_result_group(group)
640
641    module.exit_json(**res_args)
642
643
644if __name__ == "__main__":
645    main()
646