diff --git a/README-topology.md b/README-topology.md new file mode 100644 index 0000000000000000000000000000000000000000..5294a89a1cb9cdef453298d6d2d6dd911095427f --- /dev/null +++ b/README-topology.md @@ -0,0 +1,154 @@ +Topology modules +================ + +Description +----------- + +These modules allow to manage the topology. That means that topology segments can be added, removed and reinitialized. Also it is possible to verify topology suffixes. + + +Features +-------- +* Topology management + + +Supported FreeIPA Versions +-------------------------- + +FreeIPA versions 4.4.0 and up are supported by the ipatopologysegment and ipatopologysuffix modules. + + +Requirements +------------ + +**Controller** +* Ansible version: 2.8+ + +**Node** +* Supported FreeIPA version (see above) + + +Usage +===== + +Example inventory file + +```ini +[ipaserver] +ipaserver.test.local +``` + + +Example playbook to add a topology segment wiht default name (cn): + +```yaml +--- +- name: Playbook to handle topologysegment + hosts: ipaserver + become: true + + tasks: + - name: Add topology segment + ipatopologysegment: + password: MyPassword123 + suffix: domain + left: ipareplica1.test.local + right: ipareplica2.test.local + state: present +``` +The name (cn) can also be set if it should not be the default `{left}-to-{rkight}`. + + +Example playbook to delete a topology segment: + +```yaml +--- +- name: Playbook to handle topologysegment + hosts: ipaserver + become: true + + tasks: + - name: Delete topology segment + ipatopologysegment: + password: MyPassword123 + suffix: domain + left: ipareplica1.test.local + right: ipareplica2.test.local + state: absent +``` +It is possible to either use the name (cn) or left and right nodes. If left and right nodes are used, then the name will be searched and used internally. + + +Example playbook to reinitialize a topology segment: + +```yaml +--- +- name: Playbook to handle topologysegment + hosts: ipaserver + become: true + + tasks: + - name: Reinitialize topology segment + ipatopologysegment: + password: MyPassword123 + suffix: domain + left: ipareplica1.test.local + right: ipareplica2.test.local + direction: left-to-right + state: reinitialized +``` +It is possible to either use the name (cn) or left and right nodes. If left and right nodes are used, then the name will be searched and used internally. + + +Example playbook to verify a topology suffix: + +```yaml +--- +- name: Playbook to handle topologysuffix + hosts: ipaserver + become: true + + tasks: + - name: Verify topology suffix + ipatopologysuffix: + password: MyPassword123 + suffix: domain + state: verified +``` + + +Variables +========= + +ipatopologysegment +------------------ + +Variable | Description | Required +-------- | ----------- | -------- +`principal` | The admin principal is a string and defaults to `admin` | no +`password` | The admin password is a string and is required if there is no admin ticket available on the node | no +`suffix` | The topology suffix to be used, this can either be `domain` or `ca` | yes +`name` \| `cn` | The topology segment name (cn) is the unique identifier for a segment. | no +`left` \| `leftnode` | The left replication node string - an IPA server | no +`right` \| `rightnode` | The right replication node string - an IPA server | no +`direction` | The direction a segment will be reinitialized. It can either be `left-to-right` or `right-to-left` and only used with `state: reinitialized` | +`state` | The state to ensure. It can be one of `present`, `absent`, `enabled`, `disabled` or `reinitialized` | yes + + +ipatopologysuffix +----------------- + +Verify FreeIPA topology suffix + +Variable | Description | Required +-------- | ----------- | -------- +`principal` | The admin principal is a string and defaults to `admin` | no +`password` | The admin password is a string and is required if there is no admin ticket available on the node | no +`suffix` | The topology suffix to be used, this can either be `domain` or `ca` | yes +`state` | The state to ensure. It can only be `verified` | yes + + +Authors +======= + +Thomas Woerner diff --git a/README.md b/README.md index 81dc3f11291634449c019ab4743e98a07039c1fc..6e3d2853c87a118b12a28f092e01cd02dfa58659 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Features * Cluster deployments: Server, replicas and clients in one playbook * One-time-password (OTP) support for client installation * Repair mode for clients +* Modules for topology management Supported FreeIPA Versions -------------------------- @@ -307,3 +308,9 @@ Roles * [Server](roles/ipaserver/README.md) * [Replica](roles/ipareplica/README.md) * [Client](roles/ipaclient/README.md) + +Plugins in plugin/modules +========================= + +* [ipatopologysegment](README-topology.md) +* [ipatopologysuffix](README-topology.md) diff --git a/playbooks/topology/add-topologysegment.yml b/playbooks/topology/add-topologysegment.yml new file mode 100644 index 0000000000000000000000000000000000000000..a6c8477d673a15dd30265cfcd8b06a1e18c864c8 --- /dev/null +++ b/playbooks/topology/add-topologysegment.yml @@ -0,0 +1,13 @@ +--- +- name: Playbook to handle topologysegment + hosts: ipaserver + become: true + + tasks: + - name: Add topology segment + ipatopologysegment: + password: MyPassword123 + suffix: domain + left: ipareplica1.test.local + right: ipareplica2.test.local + state: present diff --git a/playbooks/topology/delete-topologysegment.yml b/playbooks/topology/delete-topologysegment.yml new file mode 100644 index 0000000000000000000000000000000000000000..af640137961b90caaf729d621e6290acd61a0a65 --- /dev/null +++ b/playbooks/topology/delete-topologysegment.yml @@ -0,0 +1,13 @@ +--- +- name: Playbook to handle topologysegment + hosts: ipaserver + become: true + + tasks: + - name: Delete topology segment + ipatopologysegment: + password: MyPassword123 + suffix: domain + left: ipareplica1.test.local + right: ipareplica2.test.local + state: absent diff --git a/playbooks/topology/reinitialize-topologysegment.yml b/playbooks/topology/reinitialize-topologysegment.yml new file mode 100644 index 0000000000000000000000000000000000000000..7afdd65a28b31cf1b8b94012b2db450f6680e331 --- /dev/null +++ b/playbooks/topology/reinitialize-topologysegment.yml @@ -0,0 +1,14 @@ +--- +- name: Playbook to handle topologysegment + hosts: ipaserver + become: true + + tasks: + - name: Reinitialize topology segment + ipatopologysegment: + password: MyPassword123 + suffix: domain + left: ipareplica1.test.local + right: ipareplica2.test.local + direction: left-to-right + state: reinitialized diff --git a/playbooks/topology/verify-topologysuffix.yml b/playbooks/topology/verify-topologysuffix.yml new file mode 100644 index 0000000000000000000000000000000000000000..518fc7c219196e3e39b31efdad64d9d2272d487e --- /dev/null +++ b/playbooks/topology/verify-topologysuffix.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to handle topologysuffix + hosts: ipaserver + become: true + + tasks: + - name: Verify topology suffix + ipatopologysuffix: + password: MyPassword123 + suffix: domain + state: verified diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py new file mode 100644 index 0000000000000000000000000000000000000000..421ee68fdb42e1ffaa5528826ec366e7cdd6c765 --- /dev/null +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -0,0 +1,122 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Thomas Woerner <twoerner@redhat.com> +# +# Copyright (C) 2019 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/>. + + +import os +import sys +import tempfile +import shutil +from ipalib import api, errors +from ipalib.config import Env +from ipalib.constants import DEFAULT_CONFIG +try: + from ipalib.install.kinit import kinit_password +except ImportError: + from ipapython.ipautil import kinit_password +from ipapython.ipautil import run +from ipaplatform.paths import paths +from ipalib.krb_utils import get_credentials_if_valid + + +def valid_creds(principal): + """ + Get valid credintials matching the princial + """ + creds = get_credentials_if_valid() + if creds and \ + creds.lifetime > 0 and \ + "%s@" % principal in creds.name.display_as(creds.name.name_type): + return True + return False + + +def temp_kinit(principal, password): + """ + kinit with password using a temporary ccache + """ + if not password: + raise RuntimeError("The password is not set") + if not principal: + principal = "admin" + + ccache_dir = tempfile.mkdtemp(prefix='krbcc') + ccache_name = os.path.join(ccache_dir, 'ccache') + + try: + kinit_password(principal, password, ccache_name) + except RuntimeError as e: + raise RuntimeError("Kerberos authentication failed: {}".format(e)) + + return ccache_dir, ccache_name + + +def temp_kdestroy(ccache_dir, ccache_name): + """ + Destroy temporary ticket and remove temporary ccache + """ + if ccache_name is not None: + run([paths.KDESTROY, '-c', ccache_name], raiseonerr=False) + if ccache_dir is not None: + shutil.rmtree(ccache_dir, ignore_errors=True) + + +def api_connect(): + """ + Create environment, initialize api and connect to ldap2 + """ + env = Env() + env._bootstrap() + env._finalize_core(**dict(DEFAULT_CONFIG)) + + api.bootstrap(context='server', debug=env.debug, log=None) + api.finalize() + api.Backend.ldap2.connect() + + +def api_command(module, command, name, args): + """ + Call ipa.Command, use AnsibleModule.fail_json for error handling + """ + try: + return api.Command[command](name, **args) + except Exception as e: + module.fail_json(msg="%s: %s" % (command, e)) + + +def execute_api_command(module, principal, password, command, name, args): + """ + Get KRB ticket if not already there, initialize api, connect, + execute command and destroy ticket again if it has been created also. + """ + ccache_dir = None + ccache_name = None + try: + if not valid_creds(principal): + ccache_dir, ccache_name = temp_kinit(principal, password) + api_connect() + + return api_command(module, command, name, args) + except Exception as e: + module.fail_json(msg=str(e)) + + finally: + temp_kdestroy(ccache_dir, ccache_name) diff --git a/plugins/modules/ipatopologysegment.py b/plugins/modules/ipatopologysegment.py new file mode 100644 index 0000000000000000000000000000000000000000..20a5d090605f0829cdceb128f6105b6d0f94ecbb --- /dev/null +++ b/plugins/modules/ipatopologysegment.py @@ -0,0 +1,288 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Thomas Woerner <twoerner@redhat.com> +# +# Copyright (C) 2019 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: ipatopologysegment +short description: Manage FreeIPA topology segments +description: Manage FreeIPA topology segments +options: + principal: + description: The admin principal + default: admin + password: + description: The admin password + required: false + suffix: + description: Topology suffix + required: true + choices: ["domain", "ca"] + name: + description: Topology segment name, unique identifier. + required: false + aliases: ["cn"] + left: + description: Left replication node - an IPA server + aliases: ["leftnode"] + right: + description: Right replication node - an IPA server + aliases: ["rightnode"] + direction: + description: The direction a segment will be reinitialized + required: false + choices: ["left-to-right", "right-to-left"] + state: + description: State to ensure + default: present + choices: ["present", "absent", "enabled", "disabled", "reinitialized"] +author: + - Thomas Woerner +""" + +EXAMPLES = """ +- ipatopologysegment: + suffix: domain + left: ipaserver.test.local + right: ipareplica1.test.local + state: present + +- ipatopologysegment: + suffix: domain + name: ipaserver.test.local-to-replica1.test.local + state: absent + +- ipatopologysegment: + suffix: domain + left: ipaserver.test.local + right: ipareplica1.test.local + state: absent + +- ipatopologysegment: + suffix: ca + name: ipaserver.test.local-to-replica1.test.local + direction: left-to-right + state: reinitialized +""" + +RETURN = """ +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.module_utils.ansible_freeipa_module import temp_kinit, \ + temp_kdestroy, valid_creds, api_connect, api_command + +def find_left_right(module, suffix, left, right): + _args = { + "iparepltoposegmentleftnode": to_text(left), + "iparepltoposegmentrightnode": to_text(right), + } + _result = api_command(module, "topologysegment_find", + to_text(suffix), _args) + if len(_result["result"]) > 1: + module.fail_json( + msg="Combination of left node '%s' and right node '%s' is " + "not unique for suffix '%s'" % (left, right, suffix)) + elif len(_result["result"]) == 1: + return _result["result"][0] + else: + return None + + +def find_cn(module, suffix, name): + _args = { + "cn": to_text(name), + } + _result = api_command(module, "topologysegment_find", + to_text(suffix), _args) + if len(_result["result"]) > 1: + module.fail_json( + msg="CN '%s' is not unique for suffix '%s'" % (name, suffix)) + elif len(_result["result"]) == 1: + return _result["result"][0] + else: + return None + + +def main(): + ansible_module = AnsibleModule( + argument_spec=dict( + principal=dict(type="str", default="admin"), + password=dict(type="str", required=False, no_log=True), + suffix=dict(choices=["domain", "ca"], required=True), + name=dict(type="str", aliases=["cn"], default=None), + left=dict(type="str", aliases=["leftnode"], default=None), + right=dict(type="str", aliases=["rightnode"], default=None), + direction=dict(type="str", default=None, + choices=["left-to-right", "right-to-left"]), + state=dict(type="str", default="present", + choices=["present", "absent", "enabled", "disabled", + "reinitialized"]), + ), + supports_check_mode=True, + ) + + ansible_module._ansible_debug = True + + # Get parameters + + principal = ansible_module.params.get("principal") + password = ansible_module.params.get("password") + suffix = ansible_module.params.get("suffix") + name = ansible_module.params.get("name") + left = ansible_module.params.get("left") + right = ansible_module.params.get("right") + direction = ansible_module.params.get("direction") + state = ansible_module.params.get("state") + + # Check parameters + + if state != "reinitialized" and direction is not None: + ansible_module.fail_json( + msg="Direction is not supported in this mode.") + + # Init + + changed = False + ccache_dir = None + ccache_name = None + try: + if not valid_creds(principal): + ccache_dir, ccache_name = temp_kinit(principal, password) + api_connect() + + command = None + + # Get name (cn) from left and right node if set for absent, disabled + # or reinitialized. + if state in ["absent", "disabled", "reinitialized"]: + if left is not None and right is not None: + left_right = find_left_right(ansible_module, suffix, + left, right) + if left_right is not None: + if name is not None and \ + left_right["cn"][0] != to_text(name): + ansible_module.fail_json( + msg="Left and right nodes do not match " + "given name name (cn) '%s'" % name) + args = { + "cn": left_right["cn"][0] + } + # else: Nothing to change + elif name is not None: + result = find_cn(ansible_module, suffix, name) + if result is not None: + args = { + "cn": result["cn"][0] + } + # else: Nothing to change + else: + ansible_module.fail_json( + msg="Either left and right or name need to be set.") + + # Create command + if state in ["present", "enabled"]: + # Make sure topology segment exists + + if left is None or right is None: + ansible_module.fail_json( + msg="Left and right need to be set.") + args = { + "iparepltoposegmentleftnode": to_text(left), + "iparepltoposegmentrightnode": to_text(right), + } + if name is not None: + args["cn"] = to_text(name) + + res_left_right = find_left_right(ansible_module, suffix, + left, right) + if res_left_right is not None: + if name is not None and \ + res_left_right["cn"][0] != to_text(name): + ansible_module.fail_json( + msg="Left and right nodes already used with " + "different name (cn) '%s'" % res_left_right["cn"]) + + # Left and right nodes and also the name can not be + # changed + for key in [ "iparepltoposegmentleftnode", + "iparepltoposegmentrightnode" ]: + if key in args: + del args[key] + if len(args) > 1: + # cn needs to be in args always + command = "topologysegment_mod" + # else: Nothing to change + else: + if name is None: + args["cn"] = to_text("%s-to-%s" % (left, right)) + command = "topologysegment_add" + + elif state in ["absent", "disabled"]: + # Make sure topology segment does not exist + + if len(args) > 0: + # Either name defined or found name from left and right node + command = "topologysegment_del" + + elif state == "reinitialized": + # Reinitialize segment + + if len(args) > 0: + # Either name defined or found name from left and right node + command = "topologysegment_reinitialize" + + if direction == "left-to-right": + args["left"] = True + elif direction == "right-to-left": + args["right"] = True + else: + ansible_module.fail_json(msg="Unknown direction '%s'" % + direction) + else: + ansible_module.fail_json(msg="Unkown state '%s'" % state) + + # Execute command + + if command is not None: + result = api_command(ansible_module, command, + to_text(suffix), args) + changed = True + + except Exception as e: + ansible_module.fail_json(msg=str(e)) + + finally: + temp_kdestroy(ccache_dir, ccache_name) + + # Done + + ansible_module.exit_json(changed=changed) + +if __name__ == "__main__": + main() diff --git a/plugins/modules/ipatopologysuffix.py b/plugins/modules/ipatopologysuffix.py new file mode 100644 index 0000000000000000000000000000000000000000..a71adc2c21707961a17624c4821b2208ac26c46e --- /dev/null +++ b/plugins/modules/ipatopologysuffix.py @@ -0,0 +1,109 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Thomas Woerner <twoerner@redhat.com> +# +# Copyright (C) 2019 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: ipatopologysuffix +short description: Verify FreeIPA topology suffix +description: Verify FreeIPA topology suffix +options: + principal: + description: The admin principal + default: admin + password: + description: The admin password + required: false + suffix: + description: Topology suffix + required: true + choices: ["domain", "ca"] + state: + description: State to ensure + default: verified + choices: ["verified"] +author: + - Thomas Woerner +""" + +EXAMPLES = """ +- ipatopologysuffix: + suffix: domain + state: verified +""" + +RETURN = """ +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.module_utils.ansible_freeipa_module import execute_api_command + +def main(): + ansible_module = AnsibleModule( + argument_spec=dict( + principal=dict(type="str", default="admin"), + password=dict(type="str", required=False, no_log=True), + suffix=dict(choices=["domain", "ca"], required=True), + state=dict(type="str", default="verified", + choices=["verified"]), + ), + supports_check_mode=True, + ) + + ansible_module._ansible_debug = True + + # Get parameters + + principal = ansible_module.params.get("principal") + password = ansible_module.params.get("password") + suffix = ansible_module.params.get("suffix") + state = ansible_module.params.get("state") + + # Check parameters + + # Init + + # Create command + + if state in ["verified"]: + command = "topologysuffix_verify" + args = {} + else: + ansible_module.fail_json(msg="Unkown state '%s'" % state) + + # Execute command + + execute_api_command(ansible_module, principal, password, + command, to_text(suffix), args) + + # Done + + ansible_module.exit_json(changed=True) + +if __name__ == "__main__": + main()