
Resolve ansible-lint issues in the role. Move all to Ansible 2.11 testing, since new molecule works with collections and TripleO will use Ansible 2.11/2.12 for deployment from Wallaby. Use FQCN for Ansible collections. Co-Author: Brendan Shephard <bshephar@redhat.com> Change-Id: I35a94d3d6335a5589a1b4ed6f3c5c599ccc59eda
411 lines
14 KiB
Python
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_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: 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()
|