Add python module to add services in batch

ansible-freeipa does not yet have the ability to add services in batch,
and adding separately leads to performance issues for large deployments.

Until this functionality is available in ansible-freeipa, use a custom
module using the python API instead.

Change-Id: I7538eaa8f7db2842ceba14cf2d6d5dd1331b972f
This commit is contained in:
Ade Lee 2023-03-17 10:24:42 +00:00 committed by Grzegorz Grasza
parent 1276985899
commit f54ffe985b
4 changed files with 325 additions and 57 deletions

View File

@ -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()

View File

@ -21,3 +21,6 @@
# enroll base server
tripleo_ipa_enroll_base_server: false
# default keytab
tripleo_ipa_keytab: "/etc/novajoin/krb5.keytab"

View File

@ -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

View File

@ -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