novajoin/novajoin/hooks.py

432 lines
15 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 os
import time
import pprint
import requests
import uuid
import kerberos
import base64
import six
import re
from oslo_config import cfg
from oslo_config import types
from oslo_log import log as logging
from oslo_serialization import jsonutils as json
from nova.i18n import _
from nova.i18n import _LE
from nova.i18n import _LI
from nova.i18n import _LW
NOVACONF = cfg.CONF
CONF = cfg.ConfigOpts()
CONF.register_opts([
cfg.StrOpt('url', default=None,
help='IPA JSON RPC URL (e.g. '
'https://ipa.host.domain/ipa/json)'),
cfg.StrOpt('keytab', default='/etc/krb5.keytab',
help='Kerberos client keytab file'),
cfg.StrOpt('service_name', default=None,
help='HTTP IPA Kerberos service name '
'(e.g. HTTP@ipa.host.domain)'),
cfg.StrOpt('domain', default='test',
help='Domain for new hosts'),
cfg.IntOpt('connect_retries', default=1,
help='How many times to attempt to retry '
'the connection to IPA before giving up'),
cfg.BoolOpt('project_subdomain', default=False,
help='Treat the project as a DNS subdomain '
'so a hostname would take the form: '
'instance.project.domain'),
cfg.BoolOpt('normalize_project', default=True,
help='Normalize the project name to be a valid DNS label'),
cfg.MultiOpt('inject_files', item_type=types.String(), default=[],
help='Files to inject into the new VM. '
'Specify as /path/to/file/on/host '
'[/path/to/file/in/vm/if/different]')
])
CONF(['--config-file', '/etc/nova/ipaclient.conf'])
LOG = logging.getLogger(__name__)
dns_regex = re.compile('[^0-9a-zA-Z]+')
class IPABaseError(Exception):
error_code = 500
error_type = 'unknown_ipa_error'
error_message = None
errors = None
def __init__(self, *args, **kwargs):
self.errors = kwargs.pop('errors', None)
self.object = kwargs.pop('object', None)
super(IPABaseError, self).__init__(*args, **kwargs)
if len(args) > 0 and isinstance(args[0], six.string_types):
self.error_message = args[0]
class IPAAuthError(IPABaseError):
error_type = 'authentication_error'
IPA_INVALID_DATA = 3009
IPA_NOT_FOUND = 4001
IPA_DUPLICATE = 4002
IPA_NO_DNS_RECORD = 4019
IPA_NO_CHANGES = 4202
class IPAUnknownError(IPABaseError):
pass
class IPACommunicationFailure(IPABaseError):
error_type = 'communication_failure'
class IPAInvalidData(IPABaseError):
error_type = 'invalid_data'
class IPADuplicateEntry(IPABaseError):
error_type = 'duplicate_entry'
ipaerror2exception = {
IPA_INVALID_DATA: {
'host': IPAInvalidData,
'dnsrecord': IPAInvalidData
},
IPA_NO_CHANGES: {
'host': None,
'dnsrecord': None
},
IPA_NO_DNS_RECORD: {
'host': None, # ignore - means already added
},
IPA_DUPLICATE: {
'host': IPADuplicateEntry,
'dnsrecord': IPADuplicateEntry
}
}
def getvmdomainname():
rv = NOVACONF.dhcp_domain or CONF.domain
LOG.debug("getvmdomainname rv = " + rv)
return rv
class IPAAuth(requests.auth.AuthBase):
def __init__(self, keytab, service):
# store the kerberos credentials in memory rather than on disk
os.environ['KRB5CCNAME'] = "MEMORY:" + str(uuid.uuid4())
self.token = None
self.keytab = keytab
self.service = service
if self.keytab:
os.environ['KRB5_CLIENT_KTNAME'] = self.keytab
else:
LOG.warn(_LW('No IPA client kerberos keytab file given'))
def __call__(self, request):
if not self.token:
self.refresh_auth()
request.headers['Authorization'] = 'negotiate ' + self.token
return request
def refresh_auth(self):
flags = kerberos.GSS_C_MUTUAL_FLAG | kerberos.GSS_C_SEQUENCE_FLAG
try:
(unused, vc) = kerberos.authGSSClientInit(self.service,
gssflags=flags)
except kerberos.GSSError as e:
LOG.error(_LE("caught kerberos exception %r") % e)
raise IPAAuthError(str(e))
try:
kerberos.authGSSClientStep(vc, "")
except kerberos.GSSError as e:
LOG.error(_LE("caught kerberos exception %r") % e)
raise IPAAuthError(str(e))
self.token = kerberos.authGSSClientResponse(vc)
class IPANovaHookBase(object):
session = None
inject_files = []
@classmethod
def start(cls):
if not cls.session:
# set up session to share among all instances
cls.session = requests.Session()
cls.session.auth = IPAAuth(CONF.keytab, CONF.service_name)
xtra_hdrs = {'Content-Type': 'application/json',
'Referer': CONF.url}
cls.session.headers.update(xtra_hdrs)
cls.session.verify = True
if not cls.inject_files:
for fn in CONF.inject_files:
hostvm = fn.split(' ')
hostfile = hostvm[0]
if len(hostvm) > 1:
vmfile = hostvm[1]
else:
vmfile = hostfile
with file(hostfile, 'r') as f:
cls.inject_files.append([vmfile,
base64.b64encode(f.read())])
def __init__(self):
IPANovaHookBase.start()
self.session = IPANovaHookBase.session
self.ntries = CONF.connect_retries
self.inject_files = IPANovaHookBase.inject_files
def _ipa_error_to_exception(self, resp, ipareq):
exc = None
if resp['error'] is None:
return exc
errcode = resp['error']['code']
method = ipareq['method']
methtype = method.split('_')[0]
exclass = ipaerror2exception.get(errcode, {}).get(methtype,
IPAUnknownError)
if exclass:
LOG.debug("Error: ipa command [%s] returned error [%s]" %
(pprint.pformat(ipareq), pprint.pformat(resp)))
elif errcode: # not mapped
LOG.debug("Ignoring IPA error code %d for command %s: %s" %
(errcode, method, pprint.pformat(resp)))
return exclass
def _call_and_handle_error(self, ipareq):
need_reauth = False
while True:
status_code = 200
try:
if need_reauth:
self.session.auth.refresh_auth()
rawresp = self.session.post(CONF.url,
data=json.dumps(ipareq))
status_code = rawresp.status_code
except IPAAuthError:
status_code = 401
if status_code == 401:
if self.ntries == 0:
# persistent inability to auth
LOG.error(_LE("Error: could not authenticate to IPA - "
"please check for correct keytab file"))
# reset for next time
self.ntries = CONF.connect_retries
raise IPACommunicationFailure()
else:
LOG.debug("Refresh authentication")
need_reauth = True
self.ntries -= 1
time.sleep(1)
else:
# successful - reset
self.ntries = CONF.connect_retries
break
try:
resp = json.loads(rawresp.text)
except ValueError:
# response was not json - some sort of error response
LOG.debug("Error: unknown error from IPA [%s]" % rawresp.text)
raise IPAUnknownError("unable to process response from IPA")
# raise the appropriate exception, if error
exclass = self._ipa_error_to_exception(resp, ipareq)
if exclass:
# could add additional info/message to exception here
raise exclass()
return resp
def _ipa_client_configured(self):
"""
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 IPABuildInstanceHook(IPANovaHookBase):
def _get_metadata(self, metadata, name, default_value=None):
"""
Try to get metadata values first from the instance properties
then the glance-provided metadata.
Returns the value if found or a default value if specified.
"""
image = metadata.get('image', {})
properties = image.get('properties', {})
instance_type = image.get('instance_type', {})
LOG.debug("instance_type: %s", instance_type)
instance_properties = metadata.get('instance_properties', {})
instance_metadata = instance_properties.get('metadata', {})
if name in instance_metadata:
return instance_metadata.get(name)
elif name in properties:
return properties.get(name)
elif name in instance_type:
return str(instance_type.get(name))
return default_value
def pre(self, *args, **kwargs):
"""
The positional arguments seem to break down into:
0 - ContextManager
1 - Context
2 - instance
3 - image
4 - request_spec
5 - filter_properties
6 - admin_password
7 - injected_files
8 - requested_networks
9 - security_groups
10 - block_device_mapping
11 - node
12 - limits
"""
LOG.debug('In IPABuildInstanceHook.pre: args [%s] kwargs [%s]',
pprint.pformat(args), pprint.pformat(kwargs))
if not self._ipa_client_configured():
LOG.debug('IPA is not configured')
return
enroll = self._get_metadata(args[4], 'ipa_enroll', '')
if enroll.lower() != 'true':
LOG.debug('IPA enrollment not requested')
return
# the injected_files parameter array values are:
# ('filename', 'base64 encoded contents')
ipaotp = uuid.uuid4().hex
ipainject = ('/tmp/ipaotp', base64.b64encode(ipaotp))
args[7].extend(self.inject_files)
args[7].append(ipainject)
ctx = args[1]
project = ctx.project_name
if CONF.normalize_project:
project = dns_regex.sub('-', project)
while project.startswith('-'):
project = project[1:]
while project.endswith('-'):
project = project[:-1]
inst = args[2]
ipareq = {'method': 'host_add', 'id': 0}
if CONF.project_subdomain:
hostname = '%s.%s.%s' % (inst.hostname, project, getvmdomainname())
else:
hostname = '%s.%s' % (inst.hostname, getvmdomainname())
params = [hostname]
hostclass = self._get_metadata(args[4], 'ipa_hostclass', '')
location = self._get_metadata(args[4], 'ipa_host_location', '')
osdistro = self._get_metadata(args[4], 'os_distro', '')
osver = self._get_metadata(args[4], 'os_version', None)
platform = self._get_metadata(args[4], 'extra_specs', None)
hostargs = {
'description': 'IPA host for %s' % inst.display_description,
'userpassword': ipaotp,
'force': True # we don't have an ip addr ye 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
if platform:
hostargs['nshardwareplatform'] = platform
ipareq['params'] = [params, hostargs]
self._call_and_handle_error(ipareq)
class IPADeleteInstanceHook(IPANovaHookBase):
def pre(self, *args, **kwargs):
LOG.debug('In IPADeleteInstanceHook.pre: args [%s] kwargs [%s]',
pprint.pformat(args), pprint.pformat(kwargs))
if not self._ipa_client_configured():
LOG.debug('IPA is not configured')
return
enroll = args[2].metadata.get('ipa_enroll', False)
if not enroll:
enroll = args[2].system_metadata.get('image_ipa_enroll', False)
if enroll.lower() != 'true':
LOG.debug('IPA enrollment not requested')
return
inst = args[2]
# call ipa host delete to remove the host
ipareq = {'method': 'host_del', 'id': 0}
hostname = '%s.%s' % (inst.hostname, getvmdomainname())
params = [hostname]
args = {
'updatedns': True,
}
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)
class IPANetworkInfoHook(IPANovaHookBase):
def post(self, *args, **kwargs):
LOG.debug('In IPANetworkInfoHook.post: args [%s] kwargs [%s]',
pprint.pformat(args), pprint.pformat(kwargs))
if not self._ipa_client_configured():
LOG.debug('IPA is not configured')
return
enroll = args[3].metadata.get('ipa_enroll', False)
if not enroll:
enroll = args[3].system_metadata.get('image_ipa_enroll', False)
if enroll.lower() != 'true':
LOG.debug('IPA enrollment not requested')
if 'nw_info' not in kwargs:
return
inst = args[3]
for fip in kwargs['nw_info'].floating_ips():
LOG.debug("IPANetworkInfoHook.post fip is [%s] [%s]",
fip, pprint.pformat(fip.__dict__))
ipareq = {'method': 'dnsrecord_add', 'id': 0}
params = [{"__dns_name__": getvmdomainname() + "."},
{"__dns_name__": inst.hostname}]
args = {'a_part_ip_address': fip['address']}
ipareq['params'] = [params, args]
self._call_and_handle_error(ipareq)