From b33c5a7bab619f4dded94285635d9cadbeca75b5 Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Thu, 16 Apr 2020 19:22:42 -0300
Subject: [PATCH] New Role management module

There is a new role management module placed in the plugins folder:

    plugins/modules/iparole.py

The role module allows to ensure presence or absence of roles and
manage role members.

Here is the documentation for the module:

    README-role.md

New example playbooks have been added:

    playbooks/role/role-is-absent.yml
    playbooks/role/role-is-present.yml
    playbooks/role/role-member-group-absent.yml
    playbooks/role/role-member-group-present.yml
    playbooks/role/role-member-host-absent.yml
    playbooks/role/role-member-host-present.yml
    playbooks/role/role-member-hostgroup-absent.yml
    playbooks/role/role-member-hostgroup-present.yml
    playbooks/role/role-member-privilege-absent.yml
    playbooks/role/role-member-privilege-present.yml
    playbooks/role/role-member-service-absent.yml
    playbooks/role/role-member-service-present.yml
    playbooks/role/role-member-user-absent.yml
    playbooks/role/role-member-user-present.yml
    playbooks/role/role-members-absent.yml
    playbooks/role/role-members-present.yml
    playbooks/role/role-rename.yml

New tests for the module:

    tests/role/test_role.yml
    tests/role/test_role_service_member.yml
---
 README-role.md                                | 264 ++++++++++
 README.md                                     |   2 +
 playbooks/role/role-is-absent.yml             |  11 +
 playbooks/role/role-is-present.yml            |  11 +
 playbooks/role/role-member-group-absent.yml   |  14 +
 playbooks/role/role-member-group-present.yml  |  13 +
 playbooks/role/role-member-host-absent.yml    |  14 +
 playbooks/role/role-member-host-present.yml   |  13 +
 .../role/role-member-hostgroup-absent.yml     |  14 +
 .../role/role-member-hostgroup-present.yml    |  13 +
 .../role/role-member-privilege-absent.yml     |  15 +
 .../role/role-member-privilege-present.yml    |  14 +
 playbooks/role/role-member-service-absent.yml |  14 +
 .../role/role-member-service-present.yml      |  13 +
 playbooks/role/role-member-user-absent.yml    |  14 +
 playbooks/role/role-member-user-present.yml   |  13 +
 playbooks/role/role-members-absent.yml        |  25 +
 playbooks/role/role-members-present.yml       |  23 +
 playbooks/role/role-rename.yml                |  11 +
 plugins/modules/iparole.py                    | 485 ++++++++++++++++++
 tests/role/env_cleanup.yml                    |  38 ++
 tests/role/env_facts.yml                      |  14 +
 tests/role/env_setup.yml                      |  34 ++
 tests/role/test_role.yml                      | 388 ++++++++++++++
 tests/role/test_role_service_member.yml       |  95 ++++
 25 files changed, 1565 insertions(+)
 create mode 100644 README-role.md
 create mode 100644 playbooks/role/role-is-absent.yml
 create mode 100644 playbooks/role/role-is-present.yml
 create mode 100644 playbooks/role/role-member-group-absent.yml
 create mode 100644 playbooks/role/role-member-group-present.yml
 create mode 100644 playbooks/role/role-member-host-absent.yml
 create mode 100644 playbooks/role/role-member-host-present.yml
 create mode 100644 playbooks/role/role-member-hostgroup-absent.yml
 create mode 100644 playbooks/role/role-member-hostgroup-present.yml
 create mode 100644 playbooks/role/role-member-privilege-absent.yml
 create mode 100644 playbooks/role/role-member-privilege-present.yml
 create mode 100644 playbooks/role/role-member-service-absent.yml
 create mode 100644 playbooks/role/role-member-service-present.yml
 create mode 100644 playbooks/role/role-member-user-absent.yml
 create mode 100644 playbooks/role/role-member-user-present.yml
 create mode 100644 playbooks/role/role-members-absent.yml
 create mode 100644 playbooks/role/role-members-present.yml
 create mode 100644 playbooks/role/role-rename.yml
 create mode 100644 plugins/modules/iparole.py
 create mode 100644 tests/role/env_cleanup.yml
 create mode 100644 tests/role/env_facts.yml
 create mode 100644 tests/role/env_setup.yml
 create mode 100644 tests/role/test_role.yml
 create mode 100644 tests/role/test_role_service_member.yml

diff --git a/README-role.md b/README-role.md
new file mode 100644
index 00000000..49e3e581
--- /dev/null
+++ b/README-role.md
@@ -0,0 +1,264 @@
+Service module
+==============
+
+Description
+-----------
+
+The role module allows to ensure presence, absence of roles and members of roles.
+
+The role module is as compatible as possible to the Ansible upstream `ipa_role` module, but additionally offers role member management.
+
+
+Features
+--------
+
+* Role management
+
+
+Supported FreeIPA Versions
+--------------------------
+
+FreeIPA versions 4.4.0 and up are supported by the iparole module.
+
+
+Requirements
+------------
+
+**Controller**
+* Ansible version: 2.8+
+
+**Node**
+* Supported FreeIPA version (see above)
+
+
+Usage
+=====
+
+Example inventory file
+
+```ini
+[ipaserver]
+ipaserver.test.local
+```
+
+
+Example playbook to make sure role is present with all members:
+
+```yaml
+---
+- name: Playbook to manage IPA role with members.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      user:
+      - pinky
+      group:
+      - group01
+      host:
+      - host01.example.com
+      hostgroup:
+      - hostgroup01
+      privilege:
+      - Group Administrators
+      - User Administrators
+      service:
+      - service01
+```
+
+Example playbook to rename a role:
+
+```yaml
+- iparole:
+    ipaadmin_password: SomeADMINpassword
+    name: somerole
+    rename: anotherrole
+```
+
+Example playbook to make sure role is absent:
+
+```yaml
+---
+- name: Playbook to manage IPA role.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      state: absent
+```
+
+Example playbook to ensure a user is a member of a role:
+
+```yaml
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      user:
+      - pinky
+      action: member
+```
+
+Example playbook to ensure a group is a member of a role:
+
+```yaml
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      host:
+      - host01.example.com
+      action: member
+```
+
+Example playbook to ensure a host is a member of a role:
+
+```yaml
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      host:
+      - host01.example.com
+      action: member
+```
+
+Example playbook to ensure a hostgroup is a member of a role:
+
+```yaml
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      hostgroup:
+      - hostgroup01
+      action: member
+```
+
+Example playbook to ensure a service is a member of a role:
+
+```yaml
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      service:
+      - service01
+      action: member
+```
+
+Example playbook to ensure a privilege is a member of a role:
+
+```yaml
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      privilege:
+      - Group Administrators
+      - User Administrators
+      action: member
+```
+
+Example playbook to ensure that different members are not associated with a role.
+
+```yaml
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      user:
+      - pinky
+      group:
+      - group01
+      host:
+      - host01.example.com
+      hostgroup:
+      - hostgroup01
+      privilege:
+      - Group Administrators
+      - User Administrators
+      service:
+      - service01
+      action: member
+      state: absent
+```
+
+
+Variables
+---------
+
+iparole
+-------
+
+Variable | Description | Required
+-------- | ----------- | --------
+`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
+`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
+`name` \| `cn` | The list of role name strings. | yes
+`description` | A description for the role. | no
+`rename` | Rename the role object. | no
+`privileges` | Privileges associated to this role. | no
+`user` | List of users to be assigned or not assigned to the role. | no
+`group` | List of groups to be assigned or not assigned to the role. | no
+`host` | List of hosts to be assigned or not assigned to the role. | no
+`hostgroup` | List of hostgroups to be assigned or not assigned to the role. | no
+`service` | List of services to be assigned or not assigned to the role. | no
+`action` | Work on role or member level. It can be on of `member` or `role` and defaults to `role`. | no
+`state` | The state to ensure. It can be one of `present`, `absent`, default: `present`. | no
+
+
+Authors
+=======
+
+Rafael Jeffman
diff --git a/README.md b/README.md
index 246a8b4c..4cc23e93 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Features
 * Modules for host management
 * Modules for hostgroup management
 * Modules for pwpolicy management
+* Modules for role management
 * Modules for service management
 * Modules for sudocmd management
 * Modules for sudocmdgroup management
@@ -421,6 +422,7 @@ Modules in plugin/modules
 * [ipahost](README-host.md)
 * [ipahostgroup](README-hostgroup.md)
 * [ipapwpolicy](README-pwpolicy.md)
+* [iparole](README-role.md)
 * [ipaservice](README-service.md)
 * [ipasudocmd](README-sudocmd.md)
 * [ipasudocmdgroup](README-sudocmdgroup.md)
diff --git a/playbooks/role/role-is-absent.yml b/playbooks/role/role-is-absent.yml
new file mode 100644
index 00000000..d8d88a1d
--- /dev/null
+++ b/playbooks/role/role-is-absent.yml
@@ -0,0 +1,11 @@
+---
+- name: Playbook to manage IPA role.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      state: absent
diff --git a/playbooks/role/role-is-present.yml b/playbooks/role/role-is-present.yml
new file mode 100644
index 00000000..89ae6b61
--- /dev/null
+++ b/playbooks/role/role-is-present.yml
@@ -0,0 +1,11 @@
+---
+- name: Playbook to manage IPA role.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      description: A role in IPA.
diff --git a/playbooks/role/role-member-group-absent.yml b/playbooks/role/role-member-group-absent.yml
new file mode 100644
index 00000000..c4695f9b
--- /dev/null
+++ b/playbooks/role/role-member-group-absent.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      group:
+      - group01
+      action: member
+      state: absent
diff --git a/playbooks/role/role-member-group-present.yml b/playbooks/role/role-member-group-present.yml
new file mode 100644
index 00000000..c14c7ec2
--- /dev/null
+++ b/playbooks/role/role-member-group-present.yml
@@ -0,0 +1,13 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      group:
+      - group01
+      action: member
diff --git a/playbooks/role/role-member-host-absent.yml b/playbooks/role/role-member-host-absent.yml
new file mode 100644
index 00000000..8acaeb28
--- /dev/null
+++ b/playbooks/role/role-member-host-absent.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      host:
+      - host01.example.com
+      action: member
+      state: absent
diff --git a/playbooks/role/role-member-host-present.yml b/playbooks/role/role-member-host-present.yml
new file mode 100644
index 00000000..58359797
--- /dev/null
+++ b/playbooks/role/role-member-host-present.yml
@@ -0,0 +1,13 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      host:
+      - host01.example.com
+      action: member
diff --git a/playbooks/role/role-member-hostgroup-absent.yml b/playbooks/role/role-member-hostgroup-absent.yml
new file mode 100644
index 00000000..ee07f97d
--- /dev/null
+++ b/playbooks/role/role-member-hostgroup-absent.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      hostgroup:
+      - hostgroup01
+      action: member
+      state: absent
diff --git a/playbooks/role/role-member-hostgroup-present.yml b/playbooks/role/role-member-hostgroup-present.yml
new file mode 100644
index 00000000..2caf9a2d
--- /dev/null
+++ b/playbooks/role/role-member-hostgroup-present.yml
@@ -0,0 +1,13 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      hostgroup:
+      - hostgroup01
+      action: member
diff --git a/playbooks/role/role-member-privilege-absent.yml b/playbooks/role/role-member-privilege-absent.yml
new file mode 100644
index 00000000..f6033904
--- /dev/null
+++ b/playbooks/role/role-member-privilege-absent.yml
@@ -0,0 +1,15 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      privilege:
+      - Group Administrators
+      - User Administrators
+      action: member
+      state: absent
diff --git a/playbooks/role/role-member-privilege-present.yml b/playbooks/role/role-member-privilege-present.yml
new file mode 100644
index 00000000..837e989f
--- /dev/null
+++ b/playbooks/role/role-member-privilege-present.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      privilege:
+      - Group Administrators
+      - User Administrators
+      action: member
diff --git a/playbooks/role/role-member-service-absent.yml b/playbooks/role/role-member-service-absent.yml
new file mode 100644
index 00000000..595047cf
--- /dev/null
+++ b/playbooks/role/role-member-service-absent.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      service:
+      - http/www.example.com
+      action: member
+      state: absent
diff --git a/playbooks/role/role-member-service-present.yml b/playbooks/role/role-member-service-present.yml
new file mode 100644
index 00000000..98dc9bea
--- /dev/null
+++ b/playbooks/role/role-member-service-present.yml
@@ -0,0 +1,13 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      service:
+      - service01
+      action: member
diff --git a/playbooks/role/role-member-user-absent.yml b/playbooks/role/role-member-user-absent.yml
new file mode 100644
index 00000000..3efda216
--- /dev/null
+++ b/playbooks/role/role-member-user-absent.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      user:
+      - pinky
+      action: member
+      state: absent
diff --git a/playbooks/role/role-member-user-present.yml b/playbooks/role/role-member-user-present.yml
new file mode 100644
index 00000000..02a39be8
--- /dev/null
+++ b/playbooks/role/role-member-user-present.yml
@@ -0,0 +1,13 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      user:
+      - pinky
+      action: member
diff --git a/playbooks/role/role-members-absent.yml b/playbooks/role/role-members-absent.yml
new file mode 100644
index 00000000..aedd81cb
--- /dev/null
+++ b/playbooks/role/role-members-absent.yml
@@ -0,0 +1,25 @@
+---
+- name: Playbook to manage IPA role member.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      user:
+      - pinky
+      group:
+      - group01
+      host:
+      - host01.example.com
+      hostgroup:
+      - hostgroup01
+      privilege:
+      - Group Administrators
+      - User Administrators
+      service:
+      - service01
+      action: member
+      state: absent
diff --git a/playbooks/role/role-members-present.yml b/playbooks/role/role-members-present.yml
new file mode 100644
index 00000000..d659c1f5
--- /dev/null
+++ b/playbooks/role/role-members-present.yml
@@ -0,0 +1,23 @@
+---
+- name: Playbook to manage IPA role with members.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      user:
+      - pinky
+      group:
+      - group01
+      host:
+      - host01.example.com
+      hostgroup:
+      - hostgroup01
+      privilege:
+      - Group Administrators
+      - User Administrators
+      service:
+      - service01
diff --git a/playbooks/role/role-rename.yml b/playbooks/role/role-rename.yml
new file mode 100644
index 00000000..9d078f52
--- /dev/null
+++ b/playbooks/role/role-rename.yml
@@ -0,0 +1,11 @@
+---
+- name: Playbook to manage IPA role.
+  hosts: ipaserver
+  become: yes
+  gather_facts: no
+
+  tasks:
+  - iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: somerole
+      rename: anotherrole
diff --git a/plugins/modules/iparole.py b/plugins/modules/iparole.py
new file mode 100644
index 00000000..cc6f6a8f
--- /dev/null
+++ b/plugins/modules/iparole.py
@@ -0,0 +1,485 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+"""ansible-freeipa iparole module implementation."""
+
+# Authors:
+#   Rafael Guterres Jeffman <rjeffman@redhat.com>
+#
+# Copyright (C) 2020 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+ANSIBLE_METADATA = {
+    "metadata_version": "1.0",
+    "supported_by": "community",
+    "status": ["preview"],
+}
+
+
+DOCUMENTATION = """
+---
+module: ipaservice
+short description: Manage FreeIPA service
+description: Manage FreeIPA service
+options:
+  ipaadmin_principal:
+    description: The admin principal.
+    default: admin
+  ipaadmin_password:
+    description: The admin password.
+    required: false
+  role:
+    description: The list of role name strings.
+    required: true
+    aliases: ["cn"]
+  description:
+    descrpition: A description for the role.
+    required: false
+  rename:
+    descrpition: Rename the role object.
+    required: false
+  user:
+    description: List of users.
+    required: false
+  group:
+    description: List of groups.
+    required: false
+  host:
+    description: List of hosts.
+    required: false
+  hostgroup:
+    description: List of hostgroups.
+    required: false
+  service:
+    description: List of services.
+    required: false
+  action:
+    description: Work on service or member level.
+    choices: ["role", "member"]
+    default: role
+    required: false
+  state:
+    description: The state to ensure.
+    choices: ["present", "absent"]
+    default: present
+    required: true
+"""
+
+EXAMPLES = """
+- name: Ensure a role named `somerole` is present.
+  iparole:
+    ipaadmin_password: SomeADMINpassword
+    name: somerole
+
+- name: Ensure user `pinky` is a memmer of role `somerole`.
+  iparole:
+    ipaadmin_password: SomeADMINpassword
+    name: somerole
+    user:
+    - pinky
+    action: member
+
+- name: Ensure a role named `somerole` is absent.
+  iparole:
+    ipaadmin_password: SomeADMINpassword
+    name: somerole
+    state: absent
+"""
+
+# pylint: disable=wrong-import-position
+# pylint: disable=import-error
+# pylint: disable=no-name-in-module
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils._text import to_text
+from ansible.module_utils.ansible_freeipa_module import \
+    temp_kinit, temp_kdestroy, valid_creds, api_connect, api_command, \
+    gen_add_del_lists, compare_args_ipa, module_params_get, api_get_realm
+import six
+
+
+if six.PY3:
+    unicode = str
+
+
+def find_role(module, name):
+    """Find if a role with the given name already exist."""
+    try:
+        _result = api_command(module, "role_show", name, {"all": True})
+    except Exception:  # pylint: disable=broad-except
+        # An exception is raised if role name is not found.
+        return None
+    else:
+        return _result["result"]
+
+
+def gen_args(module):
+    """Generate arguments for executing commands."""
+    arg_map = {
+        "description": "description",
+        "rename": "rename",
+    }
+    args = {}
+
+    for param, arg in arg_map.items():
+        value = module_params_get(module, param)
+        if value is not None:
+            args[arg] = value
+
+    return args
+
+
+def check_parameters(module):
+    """Check if parameters passed for module processing are valid."""
+    action = module_params_get(module, "action")
+    state = module_params_get(module, "state")
+
+    invalid = []
+
+    if state == "present":
+        if action == "member":
+            invalid.extend(['description', 'rename'])
+
+    if state == "absent":
+        invalid.extend(['description', 'rename'])
+        if action != "member":
+            invalid.extend(['privilege'])
+
+    for arg in invalid:
+        if module_params_get(module, arg) is not None:
+            module.fail_json(
+                msg="Argument '%s' can not be used with action '%s'" %
+                (arg, state))
+
+
+def verify_credentials(module):
+    """Ensure there are valid Kerberos credentials."""
+    ccache_dir = None
+    ccache_name = None
+
+    ipaadmin_principal = module_params_get(module, "ipaadmin_principal")
+    ipaadmin_password = module_params_get(module, "ipaadmin_password")
+
+    if not valid_creds(module, ipaadmin_principal):
+        ccache_dir, ccache_name = temp_kinit(ipaadmin_principal,
+                                             ipaadmin_password)
+
+    return (ccache_dir, ccache_name)
+
+
+def member_intersect(module, attr, memberof, res_find):
+    """Filter member arguments from role found by intersection."""
+    params = module_params_get(module, attr)
+    if not res_find:
+        return params
+    filtered = []
+    if params:
+        existing = res_find.get(memberof, [])
+        filtered = list(set(params) & set(existing))
+    return filtered
+
+
+def member_difference(module, attr, memberof, res_find):
+    """Filter member arguments from role found by difference."""
+    params = module_params_get(module, attr)
+    if not res_find:
+        return params
+    filtered = []
+    if params:
+        existing = res_find.get(memberof, [])
+        filtered = list(set(params) - set(existing))
+    return filtered
+
+
+def ensure_absent_state(module, name, action, res_find):
+    """Define commands to ensure absent state."""
+    commands = []
+
+    if action == "role":
+        commands.append([name, 'role_del', {}])
+
+    if action == "member":
+
+        members = member_intersect(
+            module, 'privilege', 'memberof_privilege', res_find)
+        if members:
+            commands.append([name, "role_remove_privilege",
+                             {"privilege": members}])
+
+        member_args = {}
+        for key in ['user', 'group', 'host', 'hostgroup']:
+            items = member_intersect(
+                        module, key, 'member_%s' % key, res_find)
+            if items:
+                member_args[key] = items
+
+        _services = filter_service(module, res_find,
+                                   lambda res, svc: res.startswith(svc))
+        if _services:
+            member_args['service'] = _services
+
+        # Only add remove command if there's at least one member no manage.
+        if member_args:
+            commands.append([name, "role_remove_member", member_args])
+
+    return commands
+
+
+def filter_service(module, res_find, predicate):
+    """
+    Filter service based on predicate.
+
+    Compare service name with existing ones matching
+    at least until `@` from principal name.
+
+    Predicate is a callable that accepts the existing service, and the
+    modified service to be compared to.
+    """
+    _services = []
+    service = module_params_get(module, 'service')
+    if service:
+        existing = [to_text(x) for x in res_find.get('member_service', [])]
+        for svc in service:
+            svc = svc if '@' in svc else ('%s@' % svc)
+            found = [x for x in existing if predicate(x, svc)]
+            _services.extend(found)
+    return _services
+
+
+def ensure_role_with_members_is_present(module, name, res_find):
+    """Define commands to ensure member are present for action `role`."""
+    commands = []
+    privilege_add, privilege_del = gen_add_del_lists(
+        module_params_get(module, "privilege"),
+        res_find.get('memberof_privilege', []))
+
+    if privilege_add:
+        commands.append([name, "role_add_privilege",
+                         {"privilege": privilege_add}])
+    if privilege_del:
+        commands.append([name, "role_remove_privilege",
+                         {"privilege": privilege_del}])
+
+    add_members = {}
+    del_members = {}
+
+    for key in ["user", "group", "host", "hostgroup"]:
+        add_list, del_list = gen_add_del_lists(
+            module_params_get(module, key),
+            res_find.get('member_%s' % key, [])
+        )
+        if add_list:
+            add_members[key] = add_list
+        if del_list:
+            del_members[key] = [to_text(item) for item in del_list]
+
+    service = [
+        to_text(svc) if '@' in svc else ('%s@%s' % (svc, api_get_realm()))
+        for svc in (module_params_get(module, 'service') or [])
+    ]
+    existing = [str(svc) for svc in res_find.get('member_service', [])]
+    add_list, del_list = gen_add_del_lists(service, existing)
+    if add_list:
+        add_members['service'] = add_list
+    if del_list:
+        del_members['service'] = [to_text(item) for item in del_list]
+
+    if add_members:
+        commands.append([name, "role_add_member", add_members])
+    if del_members:
+        commands.append([name, "role_remove_member", del_members])
+
+    return commands
+
+
+def ensure_members_are_present(module, name, res_find):
+    """Define commands to ensure members are present for action `member`."""
+    commands = []
+
+    members = member_difference(
+        module, 'privilege', 'memberof_privilege', res_find)
+    if members:
+        commands.append([name, "role_add_privilege",
+                         {"privilege": members}])
+
+    member_args = {}
+    for key in ['user', 'group', 'host', 'hostgroup']:
+        items = member_difference(
+                    module, key, 'member_%s' % key, res_find)
+        if items:
+            member_args[key] = items
+
+    _services = filter_service(module, res_find,
+                               lambda res, svc: not res.startswith(svc))
+    if _services:
+        member_args['service'] = _services
+
+    if member_args:
+        commands.append([name, "role_add_member", member_args])
+
+    return commands
+
+
+def process_command_failures(command, result):
+    """Process the result of a command, looking for errors."""
+    # Get all errors
+    # All "already a member" and "not a member" failures in the
+    # result are ignored. All others are reported.
+    errors = []
+    if "failed" in result and len(result["failed"]) > 0:
+        for item in result["failed"]:
+            failed_item = result["failed"][item]
+            for member_type in failed_item:
+                for member, failure in failed_item[member_type]:
+                    if "already a member" in failure \
+                       or "not a member" in failure:
+                        continue
+                    errors.append("%s: %s %s: %s" % (
+                        command, member_type, member, failure))
+    return errors
+
+
+def process_commands(module, commands):
+    """Process the list of IPA API commands."""
+    errors = []
+    exit_args = {}
+    changed = False
+    for name, command, args in commands:
+        try:
+            result = api_command(module, command, name, args)
+            if "completed" in result:
+                if result["completed"] > 0:
+                    changed = True
+            else:
+                changed = True
+
+            errors = process_command_failures(command, result)
+        except Exception as exception:  # pylint: disable=broad-except
+            module.fail_json(
+                msg="%s: %s: %s" % (command, name, str(exception)))
+
+    if errors:
+        module.fail_json(msg=", ".join(errors))
+
+    return changed, exit_args
+
+
+def role_commands_for_name(module, state, action, name):
+    """Define commands for the Role module."""
+    commands = []
+
+    rename = module_params_get(module, "rename")
+
+    res_find = find_role(module, name)
+
+    if state == "present":
+        args = gen_args(module)
+
+        if action == "role":
+            if res_find is None:
+                if rename is not None:
+                    module.fail_json(msg="Cannot `rename` inexistent role.")
+                commands.append([name, 'role_add', args])
+                res_find = {}
+            else:
+                if not compare_args_ipa(module, args, res_find):
+                    commands.append([name, 'role_mod', args])
+
+        if action == "member":
+            if res_find is None:
+                module.fail_json(msg="No role '%s'" % name)
+
+        cmds = ensure_role_with_members_is_present(module, name, res_find)
+        commands.extend(cmds)
+
+    if state == "absent" and res_find is not None:
+        cmds = ensure_absent_state(module, name, action, res_find)
+        commands.extend(cmds)
+
+    return commands
+
+
+def create_module():
+    """Create module description."""
+    ansible_module = AnsibleModule(
+        argument_spec=dict(
+            # generalgroups
+            ipaadmin_principal=dict(type="str", default="admin"),
+            ipaadmin_password=dict(type="str", required=False, no_log=True),
+
+            name=dict(type="list", aliases=["cn"], default=None,
+                      required=True),
+            # present
+            description=dict(required=False, type="str", default=None),
+            rename=dict(required=False, type="str", default=None),
+
+            # members
+            privilege=dict(required=False, type='list', default=None),
+            user=dict(required=False, type='list', default=None),
+            group=dict(required=False, type='list', default=None),
+            host=dict(required=False, type='list', default=None),
+            hostgroup=dict(required=False, type='list', default=None),
+            service=dict(required=False, type='list', default=None),
+
+            # state
+            action=dict(type="str", default="role",
+                        choices=["role", "member"]),
+            state=dict(type="str", default="present",
+                       choices=["present", "absent"]),
+        ),
+        supports_check_mode=True,
+        mutually_exclusive=[],
+        required_one_of=[]
+    )
+
+    ansible_module._ansible_debug = True  # pylint: disable=protected-access
+
+    return ansible_module
+
+
+def main():
+    """Process role module script."""
+    ansible_module = create_module()
+    check_parameters(ansible_module)
+
+    # Init
+    ccache_dir = None
+    ccache_name = None
+    try:
+        ccache_dir, ccache_name = verify_credentials(ansible_module)
+        api_connect()
+
+        state = module_params_get(ansible_module, "state")
+        action = module_params_get(ansible_module, "action")
+        names = module_params_get(ansible_module, "name")
+        commands = []
+
+        for name in names:
+            cmds = role_commands_for_name(ansible_module, state, action, name)
+            commands.extend(cmds)
+
+        changed, exit_args = process_commands(ansible_module, commands)
+
+    except Exception as exception:  # pylint: disable=broad-except
+        ansible_module.fail_json(msg=str(exception))
+
+    finally:
+        temp_kdestroy(ccache_dir, ccache_name)
+
+    # Done
+    ansible_module.exit_json(changed=changed, **exit_args)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/role/env_cleanup.yml b/tests/role/env_cleanup.yml
new file mode 100644
index 00000000..0a84df30
--- /dev/null
+++ b/tests/role/env_cleanup.yml
@@ -0,0 +1,38 @@
+---
+- name: Ensure test user is absent.
+  ipauser:
+    ipaadmin_password: SomeADMINpassword
+    name: user01
+    state: absent
+
+- name: Ensure test group is absent.
+  ipagroup:
+    ipaadmin_password: SomeADMINpassword
+    name: group01
+    state: absent
+
+- name: Ensure test hostgroup is absent.
+  ipahostgroup:
+    ipaadmin_password: SomeADMINpassword
+    name: hostgroup01
+    state: absent
+
+- name: Ensure test host is absent.
+  ipahost:
+    ipaadmin_password: SomeADMINpassword
+    name: "{{ host1_fqdn }}"
+    state: absent
+
+- name: Ensure test service is absent.
+  ipaservice:
+    ipaadmin_password: SomeADMINpassword
+    name: "service01/{{ host1_fqdn }}"
+    state: absent
+
+- name: Ensure test roles are absent.
+  iparole:
+    ipaadmin_password: SomeADMINpassword
+    name:
+    - renamerole
+    - testrole
+    state: absent
diff --git a/tests/role/env_facts.yml b/tests/role/env_facts.yml
new file mode 100644
index 00000000..785751d2
--- /dev/null
+++ b/tests/role/env_facts.yml
@@ -0,0 +1,14 @@
+---
+- name: Get Domain from server name
+  set_fact:
+    ipaserver_domain: "{{ ansible_fqdn | join ('.') }}"
+  when: ipaserver_domain is not defined
+
+- name: Set fact for realm name
+  set_fact:
+    ipaserver_realm: "{{ ipaserver_domain }}  | upper"
+  when: ipaserver_domain is not defined
+
+- name: Create FQDN for host01
+  set_fact:
+    host1_fqdn: "host01.{{ ipaserver_domain }}"
diff --git a/tests/role/env_setup.yml b/tests/role/env_setup.yml
new file mode 100644
index 00000000..2a876c40
--- /dev/null
+++ b/tests/role/env_setup.yml
@@ -0,0 +1,34 @@
+---
+- name: Cleanup environment.
+  import_tasks: env_cleanup.yml
+
+- name: Ensure test user is present.
+  ipauser:
+    ipaadmin_password: SomeADMINpassword
+    name: user01
+    first: First
+    last: Last
+
+- name: Ensure test group is present.
+  ipagroup:
+    ipaadmin_password: SomeADMINpassword
+    name: group01
+
+- name: Ensure test host is present.
+  ipahost:
+    ipaadmin_password: SomeADMINpassword
+    name: "{{ host1_fqdn }}"
+    force: yes
+
+- name: Ensure test hostgroup is present.
+  ipahostgroup:
+    ipaadmin_password: SomeADMINpassword
+    name: hostgroup01
+    host:
+    - "{{ host1_fqdn }}"
+
+- name: Ensure test service is present.
+  ipaservice:
+    ipaadmin_password: SomeADMINpassword
+    name: "service01/{{ host1_fqdn }}"
+    force: yes
diff --git a/tests/role/test_role.yml b/tests/role/test_role.yml
new file mode 100644
index 00000000..f72a9321
--- /dev/null
+++ b/tests/role/test_role.yml
@@ -0,0 +1,388 @@
+---
+- name: Test role module
+  hosts: ipaserver
+  become: yes
+  gather_facts: yes
+
+  tasks:
+  - name: Set environment facts.
+    import_tasks: env_facts.yml
+
+  - name: Setup environment.
+    import_tasks: env_setup.yml
+
+  # tests
+  - name: Ensure role is present.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: renamerole
+      description: A role in IPA.
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role is present, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: renamerole
+      description: A role in IPA.
+    register: result
+    failed_when: result.changed
+
+  - name: Rename role.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: renamerole
+      rename: testrole
+    register: result
+    failed_when: not result.changed
+
+  - name: Rename role, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: renamerole
+      rename: testrole
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member has privileges.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - DNS Servers
+      - Host Administrators
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member has privileges, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - DNS Servers
+      - Host Administrators
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has less privileges.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - Host Administrators
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has less privileges, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - Host Administrators
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member has privileges restored.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - DNS Servers
+      - Host Administrators
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member has privileges restored, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - DNS Servers
+      - Host Administrators
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role member privileges are absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - DNS Servers
+      - Host Administrators
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role member privileges are absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege:
+      - DNS Servers
+      - Host Administrators
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure invalid privileged is not assigned to role.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      privilege: Invalid Privilege
+      action: member
+    register: result
+    failed_when: not result.failed or "privilege not found" not in result.msg
+
+  - name: Ensure role has member user present.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      user:
+      - user01
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member user present, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      user:
+      - user01
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member user absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      user:
+      - user01
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member user absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      user:
+      - user01
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member group present.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      group:
+      - group01
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member group present, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      group:
+      - group01
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member group absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      group:
+      - group01
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member group absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      group:
+      - group01
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member host present.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      host:
+      - "{{ host1_fqdn }}"
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member host present, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      host:
+      - "{{ host1_fqdn }}"
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member host absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      host:
+      - "{{ host1_fqdn }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member host absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      host:
+      - "{{ host1_fqdn }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member hostgroup present.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      hostgroup:
+      - hostgroup01
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member hostgroup present, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      hostgroup:
+      - hostgroup01
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member hostgroup absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      hostgroup:
+      - hostgroup01
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member hostgroup absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      hostgroup:
+      - hostgroup01
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role is absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role is absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role with members is present.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      user:
+      - user01
+      group:
+      - group01
+      host:
+      - "{{ host1_fqdn }}"
+      hostgroup:
+      - hostgroup01
+      privilege:
+      - Group Administrators
+      - User Administrators
+      service:
+      - "service01/{{ host1_fqdn }}"
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role with members is present, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      user:
+      - user01
+      group:
+      - group01
+      host:
+      - "{{ host1_fqdn }}"
+      hostgroup:
+      - hostgroup01
+      privilege:
+      - Group Administrators
+      - User Administrators
+      service:
+      - "service01/{{ host1_fqdn }}"
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role is absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role is absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  # cleanup
+  - name: Cleanup environment.
+    include_tasks: env_cleanup.yml
diff --git a/tests/role/test_role_service_member.yml b/tests/role/test_role_service_member.yml
new file mode 100644
index 00000000..065cbce7
--- /dev/null
+++ b/tests/role/test_role_service_member.yml
@@ -0,0 +1,95 @@
+---
+- name: Test service member in role module.
+  hosts: ipaserver
+  become: yes
+  gather_facts: yes
+
+  tasks:
+  - name: Set environment facts.
+    import_tasks: env_facts.yml
+
+  - name: Setup environment.
+    import_tasks: env_setup.yml
+
+  # tests
+
+  - name: Ensure role with member service is present.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      service:
+      - "service01/{{ host1_fqdn }}"
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role with member service is present, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      service:
+      - "service01/{{ host1_fqdn }}"
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member service absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      service:
+      - "service01/{{ host1_fqdn }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member service absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      service:
+      - "service01/{{ host1_fqdn }}"
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role has member service with principal name.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      service:
+      - "service01/{{ host1_fqdn }}@{{ ipaserver_realm }}"
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role has member service with principal name, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      service:
+      - "service01/{{ host1_fqdn }}@{{ ipaserver_realm }}"
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure role is absent.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      state: absent
+    register: result
+    failed_when: not result.changed
+
+  - name: Ensure role is absent, again.
+    iparole:
+      ipaadmin_password: SomeADMINpassword
+      name: testrole
+      state: absent
+    register: result
+    failed_when: result.changed
+
+  # cleanup
+  - name: Cleanup environment.
+    include_tasks: env_cleanup.yml
-- 
GitLab