diff --git a/README.md b/README.md
index f3ff4aaaf81e693bd30ab33d944966b46997c569..34d8e69d980d2ad45819733797bc81c1a22d4b89 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,6 @@ Requirements
 
 **Controller**
 * Ansible version: 2.8+ (ansible-freeipa is an Ansible Collection)
-* /usr/bin/kinit is required on the controller if a one time password (OTP) is used
 
 **Node**
 * Supported FreeIPA version (see above)
@@ -289,7 +288,7 @@ ipaserver_domain=test.local
 ipaserver_realm=TEST.LOCAL
 ```
 
-For enhanced security it is possible to use a auto-generated one-time-password (OTP). This will be generated on the controller using the (first) server.
+For enhanced security it is possible to use a auto-generated one-time-password (OTP). This will be generated on the (first) server.
 
 To enable the generation of the one-time-password:
 ```yaml
diff --git a/roles/ipaclient/README.md b/roles/ipaclient/README.md
index 4804de73ce0ec3351180259aaa770d7e69017968..43a68df713691256f931ea16e3051c0b663abc27 100644
--- a/roles/ipaclient/README.md
+++ b/roles/ipaclient/README.md
@@ -32,7 +32,6 @@ Requirements
 
 **Controller**
 * Ansible version: 2.8+
-* /usr/bin/kinit is required on the controller if a one time password (OTP) is used
 
 **Node**
 * Supported FreeIPA version (see above)
@@ -172,7 +171,7 @@ Server Variables
 Variable | Description | Required
 -------- | ----------- | --------
 `ipaservers` | This group is a list of the IPA server full qualified host names. In a topology with a chain of servers and replicas, it is important to use the right server or replica as the server for the client. If there is a need to overwrite the setting for a client in the `ipaclients` group, please use the list `ipaclient_servers` explained below. If no `ipaservers` group is defined than the installation preparation step will try to use DNS autodiscovery to identify the the IPA server using DNS txt records. | mostly
-`ipaadmin_keytab` | The string variable enables the use of an admin keytab as an alternative authentication method. The variable needs to contain the local path to the keytab file. If `ipaadmin_keytab` is used, then `ipaadmin_password` does not need to be set. If `ipaadmin_keytab` is used with `ipaclient_use_otp: yes` then the keytab needs to be available on the controller, else on the client node. The use of full path names is recommended.  | no
+`ipaadmin_keytab` | The string variable enables the use of an admin keytab as an alternative authentication method. The variable needs to contain the local path to the keytab file. If `ipaadmin_keytab` is used, then `ipaadmin_password` does not need to be set. If `ipaadmin_keytab` is used with `ipaclient_use_otp: yes` then the keytab needs to be available on the controller, else on the client node. The use of full path names is recommended. | no
 `ipaadmin_principal` | The string variable only needs to be set if the name of the Kerberos admin principal is not "admin". If `ipaadmin_principal` is not set it will be set internally to "admin". | no
 `ipaadmin_password` | The string variable contains the Kerberos password of the Kerberos admin principal. If `ipaadmin_keytab` is used, then `ipaadmin_password` does not need to be set. | mostly
 
diff --git a/roles/ipaclient/action_plugins/ipaclient_get_otp.py b/roles/ipaclient/action_plugins/ipaclient_get_otp.py
deleted file mode 100644
index d6c429fdb50170a96e1d5cf765b6e502a5cc10b2..0000000000000000000000000000000000000000
--- a/roles/ipaclient/action_plugins/ipaclient_get_otp.py
+++ /dev/null
@@ -1,247 +0,0 @@
-# Authors:
-#   Florence Blanc-Renaud <frenaud@redhat.com>
-#
-# Copyright (C) 2017  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/>.
-
-from __future__ import (absolute_import, division, print_function)
-
-__metaclass__ = type
-
-import os
-import shutil
-import subprocess
-import tempfile
-from jinja2 import Template
-
-from ansible.errors import AnsibleError
-from ansible.module_utils._text import to_native
-from ansible.plugins.action import ActionBase
-
-
-def run_cmd(args, stdin=None):
-    """Execute an external command."""
-    p_in = None
-    p_out = subprocess.PIPE
-    p_err = subprocess.PIPE
-
-    if stdin:
-        p_in = subprocess.PIPE
-
-    # pylint: disable=invalid-name
-    with subprocess.Popen(
-        args, stdin=p_in, stdout=p_out, stderr=p_err, close_fds=True
-    ) as p:
-        __temp, stderr = p.communicate(stdin)
-
-        if p.returncode != 0:
-            raise RuntimeError(stderr)
-
-
-def kinit_password(principal, password, ccache_name, config):
-    """
-    Perform kinit using principal/password.
-
-    It uses the specified config file to kinit and stores the TGT
-    in ccache_name.
-    """
-    args = ["/usr/bin/kinit", principal, '-c', ccache_name]
-    old_config = os.environ.get('KRB5_CONFIG')
-    os.environ['KRB5_CONFIG'] = config
-
-    try:
-        return run_cmd(args, stdin=password.encode())
-    finally:
-        if old_config is not None:
-            os.environ['KRB5_CONFIG'] = old_config
-        else:
-            os.environ.pop('KRB5_CONFIG', None)
-
-
-def kinit_keytab(principal, keytab, ccache_name, config):
-    """
-    Perform kinit using principal/keytab.
-
-    It uses the specified config file to kinit and stores the TGT
-    in ccache_name.
-    """
-    args = ["/usr/bin/kinit", "-kt", keytab, "-c", ccache_name, principal]
-    old_config = os.environ.get('KRB5_CONFIG')
-    os.environ["KRB5_CONFIG"] = config
-
-    try:
-        return run_cmd(args)
-    finally:
-        if old_config is not None:
-            os.environ["KRB5_CONFIG"] = old_config
-        else:
-            os.environ.pop("KRB5_CONFIG", None)
-
-
-KRB5CONF_TEMPLATE = """
-[logging]
- default = FILE:/var/log/krb5libs.log
- kdc = FILE:/var/log/krb5kdc.log
- admin_server = FILE:/var/log/kadmind.log
-
-[libdefaults]
- default_realm = {{ ipa_realm }}
- dns_lookup_realm = false
- dns_lookup_kdc = true
- rdns = false
- ticket_lifetime = {{ ipa_lifetime }}
- forwardable = true
- udp_preference_limit = 0
- default_ccache_name = KEYRING:persistent:%{uid}
-
-[realms]
- {{ ipa_realm }} = {
-  kdc = {{ ipa_server }}:88
-  master_kdc = {{ ipa_server }}:88
-  admin_server = {{ ipa_server }}:749
-  default_domain = {{ ipa_domain }}
-}
-
-[domain_realm]
- .{{ ipa_domain }} = {{ ipa_realm }}
- {{ ipa_domain }} = {{ ipa_realm }}
-"""
-
-
-class ActionModule(ActionBase):  # pylint: disable=too-few-public-methods
-
-    # pylint: disable=too-many-return-statements
-    def run(self, tmp=None, task_vars=None):
-        """
-        Handle credential cache transfer.
-
-        ipa* commands can either provide a password or a keytab file
-        in order to authenticate on the managed node with Kerberos.
-        The module is using these credentials to obtain a TGT locally on the
-        control node:
-        - need to create a krb5.conf Kerberos client configuration that is
-        using IPA server
-        - set the environment variable KRB5_CONFIG to point to this conf file
-        - set the environment variable KRB5CCNAME to use a specific cache
-        - perform kinit on the control node
-        This command creates the credential cache file
-        - copy the credential cache file on the managed node
-
-        Then the IPA commands can use this credential cache file.
-        """
-        if task_vars is None:
-            task_vars = {}
-
-        # pylint: disable=super-with-arguments
-        result = super(ActionModule, self).run(tmp, task_vars)
-        principal = self._task.args.get('principal', None)
-        keytab = self._task.args.get('keytab', None)
-        password = self._task.args.get('password', None)
-        lifetime = self._task.args.get('lifetime', '1h')
-
-        if (not keytab and not password):
-            result['failed'] = True
-            result['msg'] = "keytab or password is required"
-            return result
-
-        if not principal:
-            result['failed'] = True
-            result['msg'] = "principal is required"
-            return result
-
-        data = self._execute_module(module_name='ipaclient_get_facts',
-                                    module_args={}, task_vars=task_vars)
-
-        try:
-            domain = data['ansible_facts']['ipa']['domain']
-            realm = data['ansible_facts']['ipa']['realm']
-        except KeyError:
-            result['failed'] = True
-            result['msg'] = "The host is not an IPA server"
-            return result
-
-        items = principal.split('@')
-        if len(items) < 2:
-            principal = str('%s@%s' % (principal, realm))
-
-        # Locally create a temp directory to store krb5.conf and ccache
-        local_temp_dir = tempfile.mkdtemp()
-        krb5conf_name = os.path.join(local_temp_dir, 'krb5.conf')
-        ccache_name = os.path.join(local_temp_dir, 'ccache')
-
-        # Create the krb5.conf from the template
-        template = Template(KRB5CONF_TEMPLATE)
-        content = template.render(dict(
-            ipa_server=task_vars['ansible_host'],
-            ipa_domain=domain,
-            ipa_realm=realm,
-            ipa_lifetime=lifetime))
-
-        with open(krb5conf_name, 'w') as f:  # pylint: disable=invalid-name
-            f.write(content)
-
-        if password:
-            try:
-                # perform kinit -c ccache_name -l 1h principal
-                kinit_password(principal, password, ccache_name,
-                               krb5conf_name)
-            except Exception as e:
-                result['failed'] = True
-                result['msg'] = 'kinit %s with password failed: %s' % \
-                    (principal, to_native(e))
-                return result
-
-        else:
-            # Password not supplied, need to use the keytab file
-            # Check if the source keytab exists
-            try:
-                keytab = self._find_needle('files', keytab)
-            except AnsibleError as e:
-                result['failed'] = True
-                result['msg'] = to_native(e)
-                return result
-            # perform kinit -kt keytab
-            try:
-                kinit_keytab(principal, keytab, ccache_name, krb5conf_name)
-            except Exception as e:
-                result['failed'] = True
-                result['msg'] = 'kinit %s with keytab %s failed: %s' % \
-                    (principal, keytab, str(e))
-                return result
-
-        try:
-            # Create the remote tmp dir
-            tmp = self._make_tmp_path()
-            tmp_ccache = self._connection._shell.join_path(
-                tmp, os.path.basename(ccache_name))
-
-            # Copy the ccache to the remote tmp dir
-            self._transfer_file(ccache_name, tmp_ccache)
-            self._fixup_perms2((tmp, tmp_ccache))
-
-            new_module_args = self._task.args.copy()
-            new_module_args.pop('password', None)
-            new_module_args.pop('keytab', None)
-            new_module_args.pop('lifetime', None)
-            new_module_args.update(ccache=tmp_ccache)
-
-            # Execute module
-            result.update(self._execute_module(module_args=new_module_args,
-                                               task_vars=task_vars))
-            return result
-        finally:
-            # delete the local temp directory
-            shutil.rmtree(local_temp_dir, ignore_errors=True)
diff --git a/roles/ipaclient/library/ipaclient_get_facts.py b/roles/ipaclient/library/ipaclient_get_facts.py
deleted file mode 100644
index e993139bc77dd3802472c401d428c6479976d5c7..0000000000000000000000000000000000000000
--- a/roles/ipaclient/library/ipaclient_get_facts.py
+++ /dev/null
@@ -1,282 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Authors:
-#   Thomas Woerner <twoerner@redhat.com>
-#
-# Based on ipa-client-install code
-#
-# Copyright (C) 2018-2022  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/>.
-
-from __future__ import (absolute_import, division, print_function)
-
-__metaclass__ = type
-
-DOCUMENTATION = """
----
-module: ipaclient_get_facts
-short_description: Get facts about IPA client and server configuration.
-description: Get facts about IPA client and server configuration.
-author:
-    - Thomas Woerner (@t-woerner)
-"""
-
-EXAMPLES = """
-"""
-
-RETURN = """
-ipa:
-  description: IPA configuration
-  returned: always
-  type: complex
-  contains:
-    packages:
-      description: IPA lib and server bindings
-      type: dict
-      returned: always
-      contains:
-        ipalib:
-          description: Whether ipalib.api binding could be imported.
-          type: bool
-          returned: always
-        ipaserver:
-          description: Whether ipaserver binding could be imported.
-          type: bool
-          returned: always
-    configured:
-      description: IPA components
-      type: dict
-      returned: always
-      contains:
-        client:
-          description: Whether client is configured
-          type: bool
-          returned: always
-        server:
-          description: Whether server is configured
-          type: bool
-          returned: always
-        dns:
-          description: Whether dns is configured
-          type: bool
-          returned: always
-        ca:
-          description: Whether ca is configured
-          type: bool
-          returned: always
-        kra:
-          description: Whether kra is configured
-          type: bool
-          returned: always
-        ntpd:
-          description: Whether ntpd is configured
-          type: bool
-          returned: always
-"""
-
-import os
-import re
-from ansible.module_utils import six
-try:
-    from ansible.module_utils.six.moves.configparser import RawConfigParser
-except ImportError:
-    from ConfigParser import RawConfigParser
-
-from ansible.module_utils.basic import AnsibleModule
-
-# pylint: disable=unused-import
-try:
-    from ipalib import api  # noqa: F401
-except ImportError:
-    HAS_IPALIB = False
-else:
-    HAS_IPALIB = True
-    from ipaplatform.paths import paths
-    try:
-        # FreeIPA >= 4.5
-        from ipalib.install import sysrestore
-    except ImportError:
-        # FreeIPA 4.4 and older
-        from ipapython import sysrestore
-
-try:
-    import ipaserver  # noqa: F401
-except ImportError:
-    HAS_IPASERVER = False
-else:
-    HAS_IPASERVER = True
-
-SERVER_SYSRESTORE_STATE = "/var/lib/ipa/sysrestore/sysrestore.state"
-NAMED_CONF = "/etc/named.conf"
-VAR_LIB_PKI_TOMCAT = "/var/lib/pki/pki-tomcat"
-
-
-def is_ntpd_configured():
-    # ntpd is configured when sysrestore.state contains the line
-    # [ntpd]
-    ntpd_conf_section = re.compile(r'^\s*\[ntpd\]\s*$')
-
-    try:
-        # pylint: disable=invalid-name
-        with open(SERVER_SYSRESTORE_STATE) as f:
-            for line in f.readlines():
-                if ntpd_conf_section.match(line):
-                    return True
-        # pylint: enable=invalid-name
-        return False
-    except IOError:
-        return False
-
-
-def is_dns_configured():
-    # dns is configured when /etc/named.conf contains the line
-    # dyndb "ipa" "/usr/lib64/bind/ldap.so" {
-    bind_conf_section = re.compile(r'^\s*dyndb\s+"ipa"\s+"[^"]+"\s+{$')
-
-    try:
-        with open(NAMED_CONF) as f:  # pylint: disable=invalid-name
-            for line in f.readlines():
-                if bind_conf_section.match(line):
-                    return True
-        return False
-    except IOError:
-        return False
-
-
-def is_dogtag_configured(subsystem):
-    # ca / kra is configured when the directory
-    # /var/lib/pki/pki-tomcat/[ca|kra] # exists
-    available_subsystems = {'ca', 'kra'}
-    if subsystem not in available_subsystems:
-        raise AssertionError("Subsystem '%s' not available" % subsystem)
-
-    return os.path.isdir(os.path.join(VAR_LIB_PKI_TOMCAT, subsystem))
-
-
-def is_ca_configured():
-    return is_dogtag_configured('ca')
-
-
-def is_kra_configured():
-    return is_dogtag_configured('kra')
-
-
-def is_client_configured():
-    # IPA Client is configured when /etc/ipa/default.conf exists
-    # and /var/lib/ipa-client/sysrestore/sysrestore.state exists
-
-    fstore = sysrestore.FileStore(paths.IPA_CLIENT_SYSRESTORE)
-    return os.path.isfile(paths.IPA_DEFAULT_CONF) and fstore.has_files()
-
-
-def is_server_configured():
-    # IPA server is configured when /etc/ipa/default.conf exists
-    # and /var/lib/ipa/sysrestore/sysrestore.state exists
-    return (os.path.isfile(paths.IPA_DEFAULT_CONF) and
-            os.path.isfile(SERVER_SYSRESTORE_STATE))
-
-
-def get_ipa_conf():
-    # Extract basedn, realm and domain from /etc/ipa/default.conf
-    parser = RawConfigParser()
-    parser.read(paths.IPA_DEFAULT_CONF)
-    basedn = parser.get('global', 'basedn')
-    realm = parser.get('global', 'realm')
-    domain = parser.get('global', 'domain')
-    return dict(
-        basedn=basedn,
-        realm=realm,
-        domain=domain
-    )
-
-
-def get_ipa_version():
-    try:
-        # pylint: disable=import-outside-toplevel
-        from ipapython import version
-        # pylint: enable=import-outside-toplevel
-    except ImportError:
-        return None
-    else:
-        version_info = []
-        for part in version.VERSION.split('.'):
-            # DEV versions look like:
-            # 4.4.90.201610191151GITd852c00
-            # 4.4.90.dev201701071308+git2e43db1
-            # 4.6.90.pre2
-            if part.startswith('dev') or part.startswith('pre') or \
-               'GIT' in part:
-                version_info.append(part)
-            else:
-                version_info.append(int(part))
-
-        return dict(
-            api_version=version.API_VERSION,
-            num_version=version.NUM_VERSION,
-            vendor_version=version.VENDOR_VERSION,
-            version=version.VERSION,
-            version_info=version_info
-        )
-
-
-def main():
-    module = AnsibleModule(
-        argument_spec={},
-        supports_check_mode=True
-    )
-
-    # The module does not change anything, meaning that
-    # check mode is supported
-
-    facts = dict(
-        packages=dict(
-            ipalib=HAS_IPALIB,
-            ipaserver=HAS_IPASERVER,
-        ),
-        configured=dict(
-            client=False,
-            server=False,
-            dns=False,
-            ca=False,
-            kra=False,
-            ntpd=False
-        )
-    )
-
-    if HAS_IPALIB:
-        if is_client_configured():
-            facts['configured']['client'] = True
-
-            facts['version'] = get_ipa_version()
-            for key, value in six.iteritems(get_ipa_conf()):
-                facts[key] = value
-
-    if HAS_IPASERVER:
-        if is_server_configured():
-            facts['configured']['server'] = True
-            facts['configured']['dns'] = is_dns_configured()
-            facts['configured']['ca'] = is_ca_configured()
-            facts['configured']['kra'] = is_kra_configured()
-            facts['configured']['ntpd'] = is_ntpd_configured()
-
-    module.exit_json(
-        changed=False,
-        ansible_facts=dict(ipa=facts)
-    )
-
-
-if __name__ == '__main__':
-    main()
diff --git a/roles/ipaclient/library/ipaclient_get_otp.py b/roles/ipaclient/library/ipaclient_get_otp.py
index 6be320632c59c8cd2c157192ef1a1a64779c6cb0..b69a30cff187d10c8af9476160565e4c86d61ca8 100644
--- a/roles/ipaclient/library/ipaclient_get_otp.py
+++ b/roles/ipaclient/library/ipaclient_get_otp.py
@@ -1,9 +1,9 @@
 # -*- coding: utf-8 -*-
 
 # Authors:
-#   Florence Blanc-Renaud <frenaud@redhat.com>
+#   Thomas Woerner <twoerner@redhat.com>
 #
-# Copyright (C) 2017-2022  Red Hat
+# Copyright (C) 2019-2022 Red Hat
 # see file 'COPYING' for use and warranty information
 #
 # This program is free software; you can redistribute it and/or modify
@@ -23,324 +23,267 @@ from __future__ import (absolute_import, division, print_function)
 
 __metaclass__ = type
 
-ANSIBLE_METADATA = {'metadata_version': '1.0',
-                    'status': ['preview'],
-                    'supported_by': 'community'}
+ANSIBLE_METADATA = {
+    "metadata_version": "1.0",
+    "supported_by": "community",
+    "status": ["preview"],
+}
 
-DOCUMENTATION = '''
+DOCUMENTATION = """
 ---
 module: ipaclient_get_otp
-short_description: Manage IPA hosts
-description:
-  Manage hosts in a IPA domain.
-  The operation needs to be authenticated with Kerberos either by providing
-  a password or a keytab corresponding to a principal allowed to perform
-  host operations.
+short_description: Get OTP for host enrollment
+description: Get OTP for host enrollment
 options:
-  principal:
-    description:
-      User Principal allowed to promote replicas and join IPA realm
-    type: str
-    required: no
+  ipaadmin_principal:
+    description: The admin principal.
     default: admin
-  ccache:
-    description: The local ccache
-    type: path
-    required: no
-  fqdn:
-    description:
-      The fully-qualified hostname of the host to add/modify/remove
     type: str
-    required: yes
-  certificates:
-    description: A list of host certificates
-    type: list
-    elements: str
-    required: no
-  sshpubkey:
-    description: The SSH public key for the host
+  ipaadmin_password:
+    description: |
+      The admin password. Either ipaadmin_password or ipaadmin_keytab needs
+      to be given.
+    required: false
     type: str
-    required: no
-  ipaddress:
-    description: The IP address for the host
+  ipaadmin_keytab:
+    description: |
+      The admin keytab. Either ipaadmin_password or ipaadmin_keytab needs
+      to be given.
     type: str
-    required: no
-  random:
-    description: Generate a random password to be used in bulk enrollment
-    type: bool
-    required: no
-    default: no
-  state:
-    description: The desired host state
+    required: false
+  hostname:
+    description: The FQDN hostname.
     type: str
-    choices: ['present', 'absent']
-    default: present
-    required: no
+    required: true
 author:
-    - Florence Blanc-Renaud (@flo-renaud)
-'''
-
-EXAMPLES = '''
-# Example from Ansible Playbooks
-# Add a new host with a random OTP, authenticate using principal/password
-- ipaclient_get_otp:
-    principal: admin
-    password: MySecretPassword
-    fqdn: ipaclient.ipa.domain.com
-    ipaddress: 192.168.100.23
-    random: True
-  register: result_ipaclient_get_otp
-'''
-
-RETURN = '''
+  - Thomas Woerner (@t-woerner)
+"""
+
+EXAMPLES = """
+"""
+
+RETURN = """
 host:
-  description: the host structure as returned from IPA API
+  description: Host dict with random password
   returned: always
-  type: complex
+  type: dict
   contains:
-    dn:
-      description: the DN of the host entry
-      type: str
-      returned: always
-    fqdn:
-      description: the fully qualified host name
-      type: str
-      returned: always
-    has_keytab:
-      description: whether the host entry contains a keytab
-      type: bool
-      returned: always
-    has_password:
-      description: whether the host entry contains a password
-      type: bool
-      returned: always
-    managedby_host:
-      description: the list of hosts managing the host
-      type: list
-      returned: always
     randompassword:
-      description: the OneTimePassword generated for this host
-      type: str
-      returned: changed
-    certificates:
-      description: the list of host certificates
-      type: list
-      elements: str
-      returned: when present
-    sshpubkey:
-      description: the SSH public key for the host
+      description: The generated random password
       type: str
-      returned: when present
-    ipaddress:
-      description: the IP address for the host
-      type: str
-      returned: when present
-'''
+"""
 
 import os
-
+import tempfile
+import shutil
+from contextlib import contextmanager
 from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils._text import to_text
 from ansible.module_utils import six
 
-from ansible.module_utils.ansible_ipa_client import (
-    check_imports, api, errors, paths, run
-)
+try:
+    from ipalib import api
+    from ipalib import errors as ipalib_errors  # noqa
+    from ipalib.config import Env
+    from ipaplatform.paths import paths
+    from ipapython.ipautil import run
+    from ipalib.constants import DEFAULT_CONFIG
+    try:
+        from ipalib.install.kinit import kinit_password, kinit_keytab
+    except ImportError:
+        from ipapython.ipautil import kinit_password, kinit_keytab
+except ImportError as _err:
+    MODULE_IMPORT_ERROR = str(_err)
+else:
+    MODULE_IMPORT_ERROR = None
+
 
 if six.PY3:
     unicode = str
 
 
-def get_host_diff(ipa_host, module_host):
-    """
-    Build a dict with the differences from two host dicts.
+def temp_kinit(principal, password, keytab):
+    """Kinit with password or keytab using a temporary ccache."""
+    ccache_dir = tempfile.mkdtemp(prefix='krbcc')
+    ccache_name = os.path.join(ccache_dir, 'ccache')
 
-    :param ipa_host: the host structure seen from IPA
-    :param module_host: the target host structure seen from the module params
+    try:
+        if password:
+            kinit_password(principal, password, ccache_name)
+        else:
+            kinit_keytab(principal, keytab, ccache_name)
+    except RuntimeError as e:
+        raise RuntimeError("Kerberos authentication failed: %s" % str(e))
 
-    :return: a dict representing the host attributes to apply
-    """
-    non_updateable_keys = ['ip_address']
-    data = {}
-    for key in non_updateable_keys:
-        if key in module_host:
-            del module_host[key]
-
-    for key in module_host.keys():
-        ipa_value = ipa_host.get(key, None)
-        module_value = module_host.get(key, None)
-        if isinstance(ipa_value, list) and not isinstance(module_value, list):
-            module_value = [module_value]
-        if isinstance(ipa_value, list) and isinstance(module_value, list):
-            ipa_value = sorted(ipa_value)
-            module_value = sorted(module_value)
-        if ipa_value != module_value:
-            data[key] = unicode(module_value)
-    return data
-
-
-def get_module_host(module):
-    """
-    Create a structure representing the host information.
+    os.environ["KRB5CCNAME"] = ccache_name
+    return ccache_dir, ccache_name
 
-    Reads the module parameters and builds the host structure as expected from
-    the module
-    :param module: the ansible module
-    :returns: a dict representing the host attributes
-    """
-    data = {}
-    certificates = module.params.get('certificates')
-    if certificates:
-        data['usercertificate'] = certificates
-    sshpubkey = module.params.get('sshpubkey')
-    if sshpubkey:
-        data['ipasshpubkey'] = unicode(sshpubkey)
-    ipaddress = module.params.get('ipaddress')
-    if ipaddress:
-        data['ip_address'] = unicode(ipaddress)
-    random = module.params.get('random')
-    if random:
-        data['random'] = random
-    return data
-
-
-def ensure_host_present(module, _api, ipahost):
+
+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)
+        del os.environ['KRB5CCNAME']
+    if ccache_dir is not None:
+        shutil.rmtree(ccache_dir, ignore_errors=True)
+
+
+@contextmanager
+def ipa_connect(module, principal=None, password=None, keytab=None):
     """
-    Ensure host exists in IPA and has the same attributes.
+    Create a context with a connection to IPA API.
+
+    Parameters
+    ----------
+    module: AnsibleModule
+        The AnsibleModule to use
+    principal: string
+        The optional principal name
+    password: string
+        The optional password. Either password or keytab needs to be given.
+    keytab: string
+        The optional keytab. Either password or keytab needs to be given.
 
-    :param module: the ansible module
-    :param api: IPA api handle
-    :param ipahost: the host information present in IPA, can be none if the
-                    host does not exist
     """
-    fqdn = unicode(module.params.get('fqdn'))
-    if ipahost:
-        # Host already present, need to compare the attributes
-        module_host = get_module_host(module)
-        diffs = get_host_diff(ipahost, module_host)
-
-        if not diffs:
-            # Same attributes, success
-            module.exit_json(changed=False, host=ipahost)
-
-        # Need to modify the host - only if not in check_mode
-        if module.check_mode:
-            module.exit_json(changed=True)
-
-        # If we want to create a random password, and the host
-        # already has Keytab: true, then we need first to run
-        # ipa host-disable in order to remove OTP and keytab
-        if module.params.get('random') and ipahost['has_keytab'] is True:
-            _api.Command.host_disable(fqdn)
-
-        result = _api.Command.host_mod(fqdn, **diffs)
-        # Save random password as it is not displayed by host-show
-        if module.params.get('random'):
-            randompassword = result['result']['randompassword']
-        result = _api.Command.host_show(fqdn)
-        if module.params.get('random'):
-            result['result']['randompassword'] = randompassword
-        module.exit_json(changed=True, host=result['result'])
-
-    if not ipahost:
-        # Need to add the user, only if not in check_mode
-        if module.check_mode:
-            module.exit_json(changed=True)
-
-        # Must add the user
-        module_host = get_module_host(module)
-        # force creation of host even if there is no DNS record
-        module_host["force"] = True
-        result = _api.Command.host_add(fqdn, **module_host)
-        # Save random password as it is not displayed by host-show
-        if module.params.get('random'):
-            randompassword = result['result']['randompassword']
-        result = _api.Command.host_show(fqdn)
-        if module.params.get('random'):
-            result['result']['randompassword'] = randompassword
-        module.exit_json(changed=True, host=result['result'])
-
-
-def ensure_host_absent(module, _api, host):
+    if not password and not keytab:
+        module.fail_json(msg="One of password and keytab is required.")
+    if not principal:
+        principal = "admin"
+
+    ccache_dir = None
+    ccache_name = None
+    try:
+        ccache_dir, ccache_name = temp_kinit(principal, password, keytab)
+        # api_connect start
+        env = Env()
+        env._bootstrap()
+        env._finalize_core(**dict(DEFAULT_CONFIG))
+
+        api.bootstrap(context="server", debug=env.debug, log=None)
+        api.finalize()
+
+        if api.env.in_server:
+            backend = api.Backend.ldap2
+        else:
+            backend = api.Backend.rpcclient
+
+        if not backend.isconnected():
+            backend.connect(ccache=ccache_name)
+        # api_connect end
+    except Exception as e:
+        module.fail_json(msg=str(e))
+    else:
+        try:
+            yield ccache_name
+        except Exception as e:
+            module.fail_json(msg=str(e))
+        finally:
+            temp_kdestroy(ccache_dir, ccache_name)
+
+
+def ipa_command(command, name, args):
     """
-    Ensure host does not exist in IPA.
+    Execute an IPA API command with a required `name` argument.
+
+    Parameters
+    ----------
+    command: string
+        The IPA API command to execute.
+    name: string
+        The name parameter to pass to the command.
+    args: dict
+        The parameters to pass to the command.
 
-    :param module: the ansible module
-    :param api: the IPA API handle
-    :param host: the host information present in IPA, can be none if the
-                 host does not exist
     """
-    if not host:
-        # Nothing to do, host already removed
-        module.exit_json(changed=False)
+    return api.Command[command](name, **args)
 
-    # Need to remove the host - only if not in check_mode
-    if module.check_mode:
-        module.exit_json(changed=True, host=host)
 
-    fqdn = unicode(module.params.get('fqdn'))
+def _afm_convert(value):
+    if value is not None:
+        if isinstance(value, list):
+            return [_afm_convert(x) for x in value]
+        if isinstance(value, dict):
+            return {_afm_convert(k): _afm_convert(v)
+                    for k, v in value.items()}
+        if isinstance(value, str):
+            return to_text(value)
+
+    return value
+
+
+def module_params_get(module, name):
+    return _afm_convert(module.params.get(name))
+
+
+def host_show(module, name):
+    _args = {
+        "all": True,
+    }
+
     try:
-        _api.Command.host_del(fqdn)
-    except Exception as e:
-        module.fail_json(msg="Failed to remove host: %s" % e)
+        _result = ipa_command("host_show", name, _args)
+    except ipalib_errors.NotFound as e:
+        msg = str(e)
+        if "host not found" in msg:
+            return None
+        module.fail_json(msg="host_show failed: %s" % msg)
 
-    module.exit_json(changed=True)
+    return _result["result"]
 
 
 def main():
-
     module = AnsibleModule(
         argument_spec=dict(
-            principal=dict(required=False, type='str', default='admin'),
-            ccache=dict(required=False, type='path'),
-            fqdn=dict(required=True, type='str'),
-            certificates=dict(required=False, type='list', elements='str'),
-            sshpubkey=dict(required=False, type='str'),
-            ipaddress=dict(required=False, type='str'),
-            random=dict(required=False, type='bool', default=False),
-            state=dict(required=False, type='str',
-                       choices=['present', 'absent'], default='present'),
+            ipaadmin_principal=dict(type="str", default="admin"),
+            ipaadmin_password=dict(type="str", required=False, no_log=True),
+            ipaadmin_keytab=dict(type="str", required=False, no_log=False),
+            hostname=dict(type="str", required=True),
         ),
+        mutually_exclusive=[["ipaadmin_password", "ipaadmin_keytab"]],
         supports_check_mode=True,
     )
 
-    check_imports(module)
+    if MODULE_IMPORT_ERROR is not None:
+        module.fail_json(msg=MODULE_IMPORT_ERROR)
 
-    ccache = module.params.get('ccache')
-    fqdn = unicode(module.params.get('fqdn'))
-    state = module.params.get('state')
+    # In check mode always return changed.
+    if module.check_mode:
+        module.exit_json(changed=True)
 
-    try:
-        os.environ['KRB5CCNAME'] = ccache
-
-        cfg = dict(
-            context='ansible_module',
-            confdir=paths.ETC_IPA,
-            in_server=False,
-            debug=False,
-            verbose=0,
-        )
-        api.bootstrap(**cfg)
-        api.finalize()
-        api.Backend.rpcclient.connect()
+    ipaadmin_principal = module_params_get(module, "ipaadmin_principal")
+    ipaadmin_password = module_params_get(module, "ipaadmin_password")
+    ipaadmin_keytab = module_params_get(module, "ipaadmin_keytab")
+    if ipaadmin_keytab:
+        if not os.path.exists(ipaadmin_keytab):
+            module.fail_json(msg="Unable to open ipaadmin_keytab '%s'" %
+                             ipaadmin_keytab)
 
-        try:
-            result = api.Command.host_show(fqdn, all=True)
-            host = result['result']
-        except errors.NotFound:
-            host = None
+    hostname = module_params_get(module, "hostname")
 
-        if state in ['present', 'disabled']:
-            ensure_host_present(module, api, host)
-        elif state == 'absent':
-            ensure_host_absent(module, api, host)
+    exit_args = {}
 
-    except Exception as e:
-        module.fail_json(msg="ipaclient_get_otp module failed : %s" % str(e))
-    finally:
-        run([paths.KDESTROY], raiseonerr=False, env=os.environ)
+    # Connect to IPA API
+    with ipa_connect(module, ipaadmin_principal, ipaadmin_password,
+                     ipaadmin_keytab):
+        res_show = host_show(module, hostname)
+
+        args = {"random": True}
+        if res_show is None:
+            # Create new host, force is needed to create the host without
+            # IP address.
+            args["force"] = True
+            result = ipa_command("host_add", hostname, args)
+        else:
+            # If host exists and has a keytab (is enrolled) then disable the
+            # host to be able to create a new OTP.
+            if res_show["has_keytab"]:
+                ipa_command("host_disable", hostname, {})
+            result = ipa_command("host_mod", hostname, args)
+
+        exit_args["randompassword"] = result['result']['randompassword']
 
-    module.exit_json(changed=False, host=host)
+    module.exit_json(changed=True, host=exit_args)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/roles/ipaclient/tasks/install.yml b/roles/ipaclient/tasks/install.yml
index 46cfc3aa78cdb71e9548ff1172a66bb01854d1d5..2059a4aad812d0a5633899292ca5a5c7b26420da 100644
--- a/roles/ipaclient/tasks/install.yml
+++ b/roles/ipaclient/tasks/install.yml
@@ -18,9 +18,9 @@
   when: ipaclient_no_dns_lookup | bool and groups.ipaserver is defined and
         ipaclient_servers is not defined
 
-- name: Install - Check that either principal or keytab is set
-  fail: msg="ipaadmin_principal and ipaadmin_keytab cannot be used together"
-  when: ipaadmin_keytab is defined and ipaadmin_principal is defined
+- name: Install - Check that either password or keytab is set
+  fail: msg="ipaadmin_password and ipaadmin_keytab cannot be used together"
+  when: ipaadmin_keytab is defined and ipaadmin_password is defined
 
 - name: Install - Set default principal if no keytab is given
   set_fact:
@@ -99,35 +99,44 @@
           not ipaclient_force_join | bool
 
   # The following block is executed when using OTP to enroll IPA client and
-  # the OTP isn't predefined, ie when ipaclient_use_otp is set and ipaclient_otp
-  # is not set.
+  # the OTP isn't predefined, ie when ipaclient_use_otp is set and
+  # ipaclient_otp is not set.
   # It connects to ipaserver and add the host with --random option in order
   # to create a OneTime Password
   # If a keytab is specified in the hostent, then the hostent will be disabled
   # if ipaclient_use_otp is set.
   - block:
     - name: Install - Keytab or password is required for getting otp
-      fail: msg="Keytab or password is required for getting otp"
+      ansible.builtin.fail:
+        msg: Keytab or password is required for getting otp
       when: ipaadmin_keytab is undefined and ipaadmin_password is undefined
 
+    - name: Install - Create temporary file for keytab
+      ansible.builtin.tempfile:
+        state: file
+        prefix: ipaclient_temp_
+        path: /root
+      register: keytab_temp
+      delegate_to: "{{ result_ipaclient_test.servers[0] }}"
+      when: ipaadmin_keytab is defined
+
+    - name: Install - Copy keytab to server temporary file
+      ansible.builtin.copy:
+        src: "{{ ipaadmin_keytab }}"
+        dest: "{{ keytab_temp.path }}"
+        mode: 0600
+      delegate_to: "{{ result_ipaclient_test.servers[0] }}"
+      when: ipaadmin_keytab is defined
+
     - name: Install - Get One-Time Password for client enrollment
       no_log: yes
       ipaclient_get_otp:
-        state: present
-        principal: "{{ ipaadmin_principal | default(omit) }}"
-        password: "{{ ipaadmin_password | default(omit) }}"
-        keytab: "{{ ipaadmin_keytab | default(omit) }}"
-        fqdn: "{{ result_ipaclient_test.hostname }}"
-        lifetime: "{{ ipaclient_lifetime | default(omit) }}"
-        random: True
+        ipaadmin_principal: "{{ ipaadmin_principal | default(omit) }}"
+        ipaadmin_password: "{{ ipaadmin_password | default(omit) }}"
+        ipaadmin_keytab: "{{ keytab_temp.path | default(omit) }}"
+        hostname: "{{ result_ipaclient_test.hostname }}"
       register: result_ipaclient_get_otp
-      # If the host is already enrolled, this command will exit on error
-      # The error can be ignored
-      failed_when: result_ipaclient_get_otp is failed and
-                   "Password cannot be set on enrolled host" not
-                       in result_ipaclient_get_otp.msg
       delegate_to: "{{ result_ipaclient_test.servers[0] }}"
-      ignore_errors: yes
 
     - name: Install - Report error for OTP generation
       debug:
@@ -144,6 +153,14 @@
 
     when: ipaclient_use_otp | bool and ipaclient_otp is not defined
 
+    always:
+    - name: Install - Remove keytab temporary file
+      ansible.builtin.file:
+        path: "{{ keytab_temp.path }}"
+        state: absent
+      delegate_to: "{{ result_ipaclient_test.servers[0] }}"
+      when: keytab_temp.path is defined
+
   - name: Store predefined OTP in admin_password
     no_log: yes
     set_fact:
@@ -161,7 +178,7 @@
     #        result_ipaclient_join.already_joined)))
 
     - name: Install - Check if principal and keytab are set
-      fail: msg="Principal and keytab cannot be used together"
+      fail: msg="Admin principal and client keytab cannot be used together"
       when: ipaadmin_principal is defined and ipaclient_keytab is defined
 
     - name: Install - Check if one of password or keytabs are set
diff --git a/utils/build-galaxy-release.sh b/utils/build-galaxy-release.sh
index a708d6745d36356eb4f6a8cd74e72e2a8f1b618e..bbce0122b37d2fad90e699225004a78e19f3deb6 100755
--- a/utils/build-galaxy-release.sh
+++ b/utils/build-galaxy-release.sh
@@ -123,10 +123,12 @@ sed -i -e "s/ansible.module_utils.ansible_freeipa_module/ansible_collections.${c
     ln -sf ../../roles/*/library/*.py .
 })
 
-[ ! -x plugins/action ] && mkdir plugins/action
-(cd plugins/action && {
-    ln -sf ../../roles/*/action_plugins/*.py .
-})
+# There are no action plugins anymore in the roles, therefore this section
+# is commneted out.
+#[ ! -x plugins/action ] && mkdir plugins/action
+#(cd plugins/action && {
+#    ln -sf ../../roles/*/action_plugins/*.py .
+#})
 
 for doc_fragment in plugins/doc_fragments/*.py; do
     fragment=$(basename -s .py "$doc_fragment")