novajoin/novajoin/ipa.py

559 lines
20 KiB
Python

# Copyright 2016 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.
import cachetools
import os
import time
import uuid
import six
from six.moves import http_client
try:
from gssapi.exceptions import GSSError
from ipalib import api
from ipalib import errors
ipalib_imported = True
except ImportError:
# ipalib/ipapython are not available in PyPy yet, don't make it
# a showstopper for the tests.
ipalib_imported = False
if ipalib_imported:
try:
from ipapython.ipautil import kinit_keytab
except ImportError:
# The import moved in freeIPA 4.5.0
try:
from ipalib.install.kinit import kinit_keytab
except ImportError:
ipalib_imported = False
from novajoin.util import get_domain
from oslo_config import cfg
from oslo_log import log as logging
from six.moves.configparser import SafeConfigParser
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
class IPANovaJoinBase(object):
def __init__(self, backoff=0):
try:
self.ntries = CONF.connect_retries
except cfg.NoSuchOptError:
self.ntries = 1
if not ipalib_imported:
return
self.ccache = "MEMORY:" + str(uuid.uuid4())
os.environ['KRB5CCNAME'] = self.ccache
if self._ipa_client_configured() and not api.isdone('finalize'):
(hostname, realm) = self.get_host_and_realm()
kinit_keytab(str('nova/%s@%s' % (hostname, realm)),
CONF.keytab, self.ccache)
api.bootstrap(context='novajoin')
api.finalize()
self.batch_args = list()
self.backoff = backoff
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.
IPA 4.4 introduced the requirement that the schema be
fetched when calling finalize(). This is really only used by
the ipa command-line tool but for now it is baked in.
So we have to get a TGT first but need the hostname and
realm. For now directly read the IPA config file which is
in INI format and pull those two values out and return as
a tuple.
"""
config = SafeConfigParser()
config.read('/etc/ipa/default.conf')
hostname = config.get('global', 'host')
realm = config.get('global', 'realm')
return (hostname, realm)
def __backoff(self):
LOG.debug("Backing off %s seconds", self.backoff)
time.sleep(self.backoff)
if self.backoff < 1024:
self.backoff = self.backoff * 2
def __get_connection(self):
"""Make a connection to IPA or raise an error."""
tries = 0
while (tries <= self.ntries) or (self.backoff > 0):
if self.backoff == 0:
LOG.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:
LOG.debug("kinit again: %s", e)
# pylint: disable=no-member
try:
kinit_keytab(str('nova/%s@%s' %
(api.env.host, api.env.realm)),
CONF.keytab,
self.ccache)
except GSSError as e:
LOG.debug("kinit failed: %s", e)
if tries > 0 and self.backoff:
self.__backoff()
tries += 1
except errors.NetworkError:
tries += 1
if self.backoff:
self.__backoff()
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
if self.backoff:
self.__backoff()
else:
return
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().
"""
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": command,
"params": [args, kw],
})
def flush_batch_operation(self):
"""Make an IPA batch call."""
LOG.debug("flush_batch_operation")
if not self.batch_args:
return None
kw = {}
LOG.debug(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)
LOG.debug(result)
return result
except (errors.CCacheError,
errors.TicketExpired,
errors.KerberosError):
LOG.debug("Refresh authentication")
self.__get_connection()
except errors.NetworkError:
if self.backoff:
self.__backoff()
else:
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()
if self.backoff:
self.__backoff()
else:
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')
class IPAClient(IPANovaJoinBase):
# TODO(jaosorior): Make the cache time and ttl configurable
host_cache = cachetools.TTLCache(maxsize=512, ttl=30)
service_cache = cachetools.TTLCache(maxsize=512, ttl=30)
def add_host(self, hostname, ipaotp, metadata=None, image_metadata=None):
"""Add a host to IPA.
If requested in the metadata, add a host to IPA. The assumption
is that hostname is already fully-qualified.
Because this is triggered by a metadata request, which can happen
multiple times, first we try to update the OTP in the host entry
and if that fails due to NotFound the host is added.
"""
LOG.debug('Adding ' + hostname + ' to IPA.')
if not self._ipa_client_configured():
LOG.debug('IPA is not configured')
return False
# There's no use in doing any operations if ipalib hasn't been
# imported.
if not ipalib_imported:
return True
if metadata is None:
metadata = {}
if image_metadata is None:
image_metadata = {}
if hostname in self.host_cache:
LOG.debug('Host ' + hostname + ' found in cache.')
return self.host_cache[hostname]
params = [hostname]
hostclass = metadata.get('ipa_hostclass', '')
location = metadata.get('ipa_host_location', '')
osdistro = image_metadata.get('os_distro', '')
osver = image_metadata.get('os_version', '')
# 'description': 'IPA host for %s' % inst.display_description,
hostargs = {
'description': u'IPA host for OpenStack',
'userpassword': six.text_type(ipaotp),
'force': True # we don't have an ip addr yet so
# use force to add anyway
}
if hostclass:
hostargs['userclass'] = hostclass
if osdistro or osver:
hostargs['nsosversion'] = '%s %s' % (osdistro, osver)
hostargs['nsosversion'] = hostargs['nsosversion'].strip()
if location:
hostargs['nshostlocation'] = location
modargs = {
'userpassword': six.text_type(ipaotp),
}
try:
self._call_ipa('host_mod', *params, **modargs)
self.host_cache[hostname] = six.text_type(ipaotp)
except errors.NotFound:
try:
self._call_ipa('host_add', *params, **hostargs)
self.host_cache[hostname] = six.text_type(ipaotp)
except errors.DuplicateEntry:
# We have no idea what the OTP is for the existing host.
return False
except (errors.ValidationError, errors.DNSNotARecordError):
# Assumes despite these exceptions the host was created
# and the OTP was set.
self.host_cache[hostname] = six.text_type(ipaotp)
except errors.ValidationError:
# Updating the OTP on an enrolled-host is not allowed
# in IPA and really a no-op.
# We don't know the OTP of the host, so we cannot update the cache.
return False
return self.host_cache.get(hostname, False)
def add_subhost(self, hostname):
"""Add a subhost to 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',
"""
LOG.debug('Adding subhost: ' + hostname)
if hostname not in self.host_cache:
params = [hostname]
hostargs = {'force': True}
self._add_batch_operation('host_add', *params, **hostargs)
self.host_cache[hostname] = True
else:
LOG.debug('subhost ' + hostname + ' found in cache.')
def delete_subhost(self, hostname, batch=True):
"""Delete a subhost 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',
"""
LOG.debug('Deleting subhost: ' + 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:
if hostname in self.host_cache:
del self.host_cache[hostname]
self._add_batch_operation('host_del', *host_params, **host_kw)
self._add_batch_operation('dnsrecord_del', *dns_params,
**dns_kw)
else:
if hostname in self.host_cache:
del self.host_cache[hostname]
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 delete_host(self, hostname, metadata=None):
"""Delete a host from IPA and remove all related DNS entries."""
LOG.debug('Deleting ' + hostname + ' from IPA.')
if not self._ipa_client_configured():
LOG.debug('IPA is not configured')
return
if metadata is None:
metadata = {}
# TODO(rcrit): lookup instance in nova to get metadata to see if
# the host was enrolled. For now assume yes.
params = [hostname]
kw = {
'updatedns': False,
}
try:
if hostname in self.host_cache:
del self.host_cache[hostname]
self._call_ipa('host_del', *params, **kw)
except (errors.NotFound, errors.ACIError):
# Trying to delete a host that doesn't exist will raise an ACIError
# to hide whether the entry exists or not
pass
(hn, domain) = self.split_hostname(hostname)
dns_params = [domain, hn]
dns_kw = {'del_all': True, }
try:
self._call_ipa('dnsrecord_del', *dns_params, **dns_kw)
except (errors.NotFound, errors.ACIError):
# Ignore DNS deletion errors
pass
def add_service(self, principal):
if principal not in self.service_cache:
try:
(service, hostname, realm) = self.split_principal(principal)
except errors.MalformedServicePrincipal as e:
LOG.error("Unable to split principal %s: %s", principal, e)
raise
LOG.debug('Adding service: ' + principal)
params = [principal]
service_args = {'force': True}
self._add_batch_operation('service_add', *params, **service_args)
self.service_cache[principal] = [hostname]
else:
LOG.debug('Service ' + principal + ' found in cache.')
def service_add_host(self, service_principal, host):
"""Add a host to a service.
In IPA there is a relationship between a host and the services for
that host. The host has the right to manage keytabs and SSL
certificates for its own services. There are reasons that a host
may want to manage services for another host or service:
virtualization, load balancing, etc. In order to do this you mark
the host or service as being "managed by" another host. For services
in IPA this is done using the service-add-host API.
"""
if host not in self.service_cache.get(service_principal, []):
LOG.debug('Adding principal ' + service_principal +
' to host ' + host)
params = [service_principal]
service_args = {'host': (host,)}
self._add_batch_operation('service_add_host', *params,
**service_args)
self.service_cache[service_principal] = self.service_cache.get(
service_principal, []) + [host]
else:
LOG.debug('Host ' + host + ' managing ' + service_principal +
' found in cache.')
def service_has_hosts(self, service_principal):
"""Return True if hosts other than parent manages this service"""
LOG.debug('Checking if principal ' + service_principal + ' has hosts')
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:
LOG.error("Unable to split principal %s: %s", service_principal, e)
raise
for candidate in serviceresult.get('managedby_host', []):
if candidate != hostname:
return True
return False
def host_has_services(self, service_host):
"""Return True if this host manages any services"""
LOG.debug('Checking if host ' + service_host + ' has services')
params = []
service_args = {'man_by_host': service_host}
result = self._call_ipa('service_find', *params, **service_args)
return result['count'] > 0
def find_host(self, hostname):
"""Return True if this host exists"""
LOG.debug('Checking if host ' + hostname + ' exists')
params = []
service_args = {'fqdn': six.text_type(hostname)}
result = self._call_ipa('host_find', *params, **service_args)
return result['count'] > 0
def delete_service(self, principal, batch=True):
LOG.debug('Deleting service: ' + principal)
params = [principal]
service_args = {}
if batch:
if principal in self.service_cache:
del self.service_cache[principal]
self._add_batch_operation('service_del', *params, **service_args)
else:
if principal in self.service_cache:
del self.service_cache[principal]
return self._call_ipa('service_del', *params, **service_args)
def add_ip(self, hostname, floating_ip):
"""Add a floating IP to a given hostname."""
LOG.debug('In add_ip')
if not self._ipa_client_configured():
LOG.debug('IPA is not configured')
return
params = [six.text_type(get_domain() + '.'),
six.text_type(hostname)]
kw = {'a_part_ip_address': six.text_type(floating_ip)}
try:
self._call_ipa('dnsrecord_add', *params, **kw)
except (errors.DuplicateEntry, errors.ValidationError):
pass
def find_record(self, floating_ip):
"""Find DNS A record for floating IP address"""
LOG.debug('looking up host for floating ip' + floating_ip)
params = [six.text_type(get_domain() + '.')]
service_args = {'arecord': six.text_type(floating_ip)}
result = self._call_ipa('dnsrecord_find', *params, **service_args)
if result['count'] == 0:
return
assert(result['count'] == 1)
return result['result'][0]['idnsname'][0].to_unicode()
def remove_ip(self, floating_ip):
"""Remove a floating IP from a given hostname."""
LOG.debug('In remove_ip')
if not self._ipa_client_configured():
LOG.debug('IPA is not configured')
return
hostname = self.find_record(floating_ip)
if not hostname:
LOG.debug('floating IP record not found')
return
params = [six.text_type(get_domain() + '.'), hostname]
service_args = {'arecord': six.text_type(floating_ip)}
self._call_ipa('dnsrecord_del', *params, **service_args)