tripleo-ipa/tripleo_ipa/ansible_plugins/modules/cleanup_ipa_services.py

411 lines
14 KiB
Python

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright 2020 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.module_utils.openstack import openstack_full_argument_spec
from ansible.module_utils.openstack import openstack_module_kwargs
ANSIBLE_METADATA = {
'metadata_version': '1.0',
'status': ['preview'],
'supported_by': 'community'
}
DOCUMENTATION = '''
---
module: cleanup_ipa_services
short_description: Cleanup IPA Services and Hosts
version_added: "2.8"
description:
- When hosts are deleted, delete the hosts, subhosts and services
associated with the hosts in the FreeIPA server.
- If the services are managed exclusively by the hosts, then
delete the subhost for that service and the service itself.
- If the service is managed by other hosts (not being deleted),
then simply remove the host(s) being deleted from the managed_by
attribute.
options:
keytab:
description:
- Keytab to use when authenticating to FreeIPA
type: str
hosts:
description:
- Hosts to be deleted (list of FQDNs)
type: list
author:
- Ade Lee (@vakwetu)
'''
EXAMPLES = '''
- name: Cleanup IPA hosts and services
cleanup_ipa_services:
keytab: /etc/krb5.keytab
hosts:
- test-server-0.exmaple.com
- test-server-1.example.com
- test-server-2.example.com
'''
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()
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()
def split_principal(self, principal):
"""Split a principal into its components. Copied from IPA 4.0.0"""
service = hostname = realm = None
# Break down the principal into its component parts, which may or
# may not include the realm.
sp = principal.split('/')
if len(sp) != 2:
raise errors.MalformedServicePrincipal(reason='missing service')
service = sp[0]
if len(service) == 0:
raise errors.MalformedServicePrincipal(reason='blank service')
sr = sp[1].split('@')
if len(sr) > 2:
raise errors.MalformedServicePrincipal(
reason='unable to determine realm')
hostname = sr[0].lower()
if len(sr) == 2:
realm = sr[1].upper()
# At some point we'll support multiple realms
if realm != api.env.realm:
raise errors.RealmMismatch()
else:
realm = api.env.realm
# Note that realm may be None.
return (service, hostname, realm)
def split_hostname(self, hostname):
"""Split a hostname into its host and domain parts"""
parts = hostname.split('.')
domain = six.text_type('.'.join(parts[1:]) + '.')
return (parts[0], domain)
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_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 delete_host(self, hostname, batch=True):
"""Delete a host from IPA.
Servers can have multiple network interfaces, and therefore can
have multiple aliases. Moreover, they can part of a service using
a virtual host (VIP). These aliases are denoted 'subhosts',
"""
logging.debug("Deleting subhost: %s", hostname)
host_params = [hostname]
(hn, domain) = self.split_hostname(hostname)
dns_params = [domain, hn]
# If there is no DNS entry, this operation fails
host_kw = {'updatedns': False, }
dns_kw = {'del_all': True, }
if batch:
self._add_batch_operation('host_del', *host_params, **host_kw)
self._add_batch_operation('dnsrecord_del', *dns_params,
**dns_kw)
else:
self._call_ipa('host_del', *host_params, **host_kw)
try:
self._call_ipa('dnsrecord_del',
*dns_params, **dns_kw)
except (errors.NotFound, errors.ACIError):
# Ignore DNS deletion errors
pass
def host_get_services(self, service_host):
"""Return list of services this host manages"""
logging.debug("Checking host %s services", service_host)
params = []
service_args = {'man_by_host': six.text_type(service_host)}
result = self._call_ipa('service_find',
*params, **service_args)
return [service['krbprincipalname'][0] for service in result['result']]
def service_managed_by_other_hosts(self, service_principal,
hosts_to_be_deleted):
"""Return True if hosts other than parent manages this service"""
logging.debug("Checking if principal %s has hosts", service_principal)
params = [service_principal]
service_args = {}
try:
result = self._call_ipa('service_show',
*params, **service_args)
except errors.NotFound:
raise KeyError
serviceresult = result['result']
try:
(service, hostname, realm) = self.split_principal(
service_principal
)
except errors.MalformedServicePrincipal as e:
logging.error("Unable to split principal %s: %s",
service_principal, e)
raise
for candidate in serviceresult.get('managedby_host', []):
if candidate != hostname:
if candidate not in hosts_to_be_deleted:
return True
return False
def find_host(self, hostname):
"""Return True if this host exists"""
logging.debug("Checking if host %s exists", hostname)
params = []
service_args = {'fqdn': six.text_type(hostname)}
result = self._call_ipa('host_find',
*params, **service_args)
return result['count'] > 0
def cleanup_ipa_services(keytab, hosts):
ipa = IPAClient(keytab)
hosts_to_delete = set()
for host in hosts:
if six.PY3:
hostname = host
else:
hostname = host.decode('UTF-8')
if ipa.find_host(hostname):
hosts_to_delete.add(hostname)
# get a list of all the services associated with a given hosts
principals = set()
for host in hosts_to_delete:
principals.update(ipa.host_get_services(host))
# Check the managed_by attribute of each service identified with
# the given host. If it is managed by a host other than the
# parent or the hosts to be deleted, then it is likely a VIP and it
# is not ready to be removed.
subhosts_to_delete = set()
for principal in principals:
(service, subhost, domain) = ipa.split_principal(principal)
if ipa.service_managed_by_other_hosts(principal, hosts_to_delete):
# this service still has other hosts
continue
subhosts_to_delete.add(subhost)
# delete the subhosts. Referential integrity should take care of the
# services associated with these hosts.
ipa.start_batch_operation()
for host in hosts_to_delete:
ipa.delete_host(host)
for subhost in subhosts_to_delete:
ipa.delete_host(subhost)
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')
hosts = module.params.get('hosts')
cleanup_ipa_services(keytab, hosts)
module.exit_json(changed=True)
except Exception as err:
module.fail_json(msg=str(err))
def main():
run_module()
if __name__ == '__main__':
main()