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:
parent
1276985899
commit
f54ffe985b
|
@ -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()
|
|
@ -21,3 +21,6 @@
|
|||
|
||||
# enroll base server
|
||||
tripleo_ipa_enroll_base_server: false
|
||||
|
||||
# default keytab
|
||||
tripleo_ipa_keytab: "/etc/novajoin/krb5.keytab"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue