diff --git a/tripleo_ipa/ansible_plugins/modules/add_ipa_services.py b/tripleo_ipa/ansible_plugins/modules/add_ipa_services.py new file mode 100644 index 0000000..6167291 --- /dev/null +++ b/tripleo_ipa/ansible_plugins/modules/add_ipa_services.py @@ -0,0 +1,316 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging +import os +import time +import uuid +import yaml + +import six +from six.moves import http_client +from six.moves.configparser import SafeConfigParser + +from gssapi.exceptions import GSSError +from ipalib import api +from ipalib import errors + +try: + from ipapython.ipautil import kinit_keytab +except ImportError: + # The import moved in freeIPA 4.5.0 + from ipalib.install.kinit import kinit_keytab + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import openstack_full_argument_spec +from ansible_collections.openstack.cloud.plugins.module_utils.openstack import openstack_module_kwargs + +ANSIBLE_METADATA = { + 'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: add_ipa_services + +short_description: Add IPA Services and Sub hosts + +version_added: "2.8" + +description: + - Adds IPS services and hosts in a batch operation. This is a + workaround until this ability is added to ansible-freeipa. + +options: + keytab: + description: + - Keytab to use when authenticating to FreeIPA + type: str + base_host: + description: + - Base host to be added to managed_by attribute + type: str + services: + description: + - List of services to be added. Each service is of the form + ('subhost', 'service') + type: list +author: + - Ade Lee (@vakwetu) +''' + +EXAMPLES = ''' +- name: Add IPA sub-hosts and services + add_ipa_services: + keytab: /etc/krb5.keytab + base_host: test-server-0.example.com + services: + - ("test-server-0.ctlplane.example.com", "http") + - ("test-server-0.storage.example.com", "http") + - ("test-server-0.ctlplane.example.com", "haproxy") +''' + + +class IPAClient(object): + + def __init__(self, keytab): + self.ntries = 5 + self.retry_delay = 2 + self.keytab = keytab + + if self._ipa_client_configured() and not api.isdone('finalize'): + self.ccache = "MEMORY:" + str(uuid.uuid4()) + os.environ['KRB5CCNAME'] = self.ccache + (hostname, realm) = self.get_host_and_realm() + self.realm = realm + kinit_keytab(str('nova/%s@%s' % (hostname, realm)), + self.keytab, self.ccache) + api.bootstrap(context='cleanup') + api.finalize() + else: + self.ccache = os.environ['KRB5CCNAME'] + self.batch_args = list() + self.hosts_added = set() + self.services_added = dict() + + def get_host_and_realm(self): + """Return the hostname and IPA realm name.""" + config = SafeConfigParser() + config.read('/etc/ipa/default.conf') + hostname = config.get('global', 'host') + realm = config.get('global', 'realm') + return (hostname, realm) + + def get_principal(self, subhost, service): + return service + '/' + subhost + '@' + self.realm + + def __get_connection(self): + """Make a connection to IPA or raise an error.""" + tries = 0 + + while (tries <= self.ntries): + logging.debug("Attempt %d of %d", tries, self.ntries) + if api.Backend.rpcclient.isconnected(): + api.Backend.rpcclient.disconnect() + try: + api.Backend.rpcclient.connect() + # ping to force an actual connection in case there is only one + # IPA master + api.Command[u'ping']() + except (errors.CCacheError, + errors.TicketExpired, + errors.KerberosError) as e: + tries += 1 + + # pylint: disable=no-member + logging.debug("kinit new ccache in get_connection: %s", e) + try: + kinit_keytab(str('nova/%s@%s' % + (api.env.host, api.env.realm)), + self.keytab, + self.ccache) + except GSSError as e: + logging.debug("kinit failed: %s", e) + except errors.NetworkError: + tries += 1 + except http_client.ResponseNotReady: + # NOTE(xek): This means that the server closed the socket, + # so keep-alive ended and we can't use that connection. + api.Backend.rpcclient.disconnect() + tries += 1 + else: + # successful connection + return + logging.debug("Waiting %s seconds before next retry.", + self.retry_delay) + time.sleep(self.retry_delay) + + logging.error(" Failed to connect to IPA after %d attempts", + self.ntries) + raise Exception("Failed to connect to IPA") + + def start_batch_operation(self): + """Start a batch operation. + + IPA method calls will be collected in a batch job + and submitted to IPA once all the operations have collected + by a call to _flush_batch_operation(). + """ + logging.debug("start batch operation") + self.batch_args = list() + + def _add_batch_operation(self, command, *args, **kw): + """Add an IPA call to the batch operation""" + self.batch_args.append({ + "method": six.text_type(command), + "params": [args, kw], + }) + + def flush_batch_operation(self): + """Make an IPA batch call.""" + logging.debug("flush_batch_operation") + if not self.batch_args: + return None + + kw = {} + logging.debug(" %s", self.batch_args) + + return self._call_ipa('batch', *self.batch_args, **kw) + + def _call_ipa(self, command, *args, **kw): + """Make an IPA call.""" + if not api.Backend.rpcclient.isconnected(): + self.__get_connection() + if 'version' not in kw: + kw['version'] = u'2.146' # IPA v4.2.0 for compatibility + + while True: + try: + result = api.Command[command](*args, **kw) + logging.debug(result) + return result + except (errors.CCacheError, + errors.TicketExpired, + errors.KerberosError): + logging.debug("Refresh authentication") + self.__get_connection() + except errors.NetworkError: + raise + except http_client.ResponseNotReady: + # NOTE(xek): This means that the server closed the socket, + # so keep-alive ended and we can't use that connection. + api.Backend.rpcclient.disconnect() + raise + + def _ipa_client_configured(self): + """Determine if the machine is an enrolled IPA client. + + Return boolean indicating whether this machine is enrolled + in IPA. This is a rather weak detection method but better + than nothing. + """ + + return os.path.exists('/etc/ipa/default.conf') + + def add_subhost(self, hostname): + """Add a subhost to IPA. """ + + logging.debug('Adding subhost: %s', hostname) + if hostname not in self.hosts_added: + params = [hostname] + hostargs = {'force': True} + self._add_batch_operation('host_add', *params, **hostargs) + self.hosts_added.add(hostname) + else: + logging.debug("subhost %s already added", hostname) + + def add_service(self, principal): + if principal not in self.services_added: + logging.debug("Adding service: %s", principal) + params = [principal] + service_args = {'force': True} + self._add_batch_operation('service_add', *params, **service_args) + self.services_added[principal] = set() + else: + logging.debug("Service %s already added", principal) + + def add_host_to_service(self, principal, host): + """Add a host to a service. """ + if host not in self.services_added[principal]: + logging.debug("Adding principal %s to host %s", + principal, host) + params = [principal] + service_args = {'host': (host,)} + self._add_batch_operation('service_add_host', *params, + **service_args) + self.services_added[principal].add(host) + else: + logging.debug("Host %s managing %s already added to service", + host, principal) + + +def add_ipa_services(keytab, base_host, services): + ipa = IPAClient(keytab) + + ipa.start_batch_operation() + for (subhost, service) in services: + # add sub host + ipa.add_subhost(subhost) + + # add service + principal = ipa.get_principal(subhost, service) + ipa.add_service(principal) + ipa.add_host_to_service(principal, base_host) + + ipa.flush_batch_operation() + + +def run_module(): + argument_spec = openstack_full_argument_spec( + **yaml.safe_load(DOCUMENTATION)['options'] + ) + + module = AnsibleModule( + argument_spec, + supports_check_mode=True, + **openstack_module_kwargs() + ) + + try: + keytab = module.params.get('keytab') + base_host = module.params.get('base_host') + services = module.params.get('services') + + add_ipa_services(keytab, base_host, services) + + module.exit_json(changed=True) + except Exception as err: + module.fail_json(msg=str(err)) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/tripleo_ipa/roles/tripleo_ipa_registration/defaults/main.yml b/tripleo_ipa/roles/tripleo_ipa_registration/defaults/main.yml index 5ac41c2..aada6af 100644 --- a/tripleo_ipa/roles/tripleo_ipa_registration/defaults/main.yml +++ b/tripleo_ipa/roles/tripleo_ipa_registration/defaults/main.yml @@ -21,3 +21,6 @@ # enroll base server tripleo_ipa_enroll_base_server: false + +# default keytab +tripleo_ipa_keytab: "/etc/novajoin/krb5.keytab" diff --git a/tripleo_ipa/roles/tripleo_ipa_registration/tasks/main.yml b/tripleo_ipa/roles/tripleo_ipa_registration/tasks/main.yml index 9289e2d..5e67844 100644 --- a/tripleo_ipa/roles/tripleo_ipa_registration/tasks/main.yml +++ b/tripleo_ipa/roles/tripleo_ipa_registration/tasks/main.yml @@ -61,8 +61,9 @@ delegate_to: "{{ tripleo_ipa_delegate_server }}" when: "'host' in ipa_host" -- name: add required services - include: services.yml - loop: "{{ tripleo_ipa_server_metadata | from_json | parse_service_metadata(base_server_fqdn) }}" - loop_control: - loop_var: required_service +- name: add required services using custom module + add_ipa_services: + keytab: "{{ tripleo_ipa_keytab }}" + base_host: "{{ base_server_fqdn }}" + services: "{{ tripleo_ipa_server_metadata | from_json | parse_service_metadata(base_server_fqdn) }}" + become: true diff --git a/tripleo_ipa/roles/tripleo_ipa_registration/tasks/services.yml b/tripleo_ipa/roles/tripleo_ipa_registration/tasks/services.yml deleted file mode 100644 index 4bade11..0000000 --- a/tripleo_ipa/roles/tripleo_ipa_registration/tasks/services.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- -# Copyright 2020 Red Hat, Inc. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# The tasks in this file perform the registration process for a service. -# -# The following variable are required: -# - { required_service } : which is an ordered tuple of the form: -# -- { sub_host, service } -# -# An example of this is: -# { "controller-5.storagemgmt.example.com", "haproxy" } -# -# At this time, the final value in the tuple is unused. - -- name: set variables - set_fact: - sub_host: "{{ required_service.0 }}" - service: "{{ required_service.1 }}" - -- name: add sub_host - ipahost: - fqdn: "{{ sub_host }}" - force: true - state: present - become: true - -- name: add service - ipaservice: - name: "{{ service }}/{{ sub_host }}" - force: true - state: present - become: true - -- name: add host to managed_hosts if needed (shell) - shell: | - ipa service-add-host --hosts "{{ base_server_fqdn }}" "{{ service }}"/"{{ sub_host }}" - register: service_add_out - failed_when: service_add_out.failed and 'This entry is already a member' not in service_add_out.stdout - become: true