charm-keystone/hooks/manager.py

591 lines
22 KiB
Python
Executable File

#!/usr/bin/env python2
#
# Copyright 2016 Canonical Ltd
#
# 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.
# NOTE(tinwood): This file needs to remain Python2 as it uses keystoneclient
# from the payload software to do it's work.
from __future__ import print_function
import json
import os
import stat
import sys
import time
from keystoneclient.v2_0 import client
from keystoneclient.v3 import client as keystoneclient_v3
from keystoneclient.auth import token_endpoint
from keystoneclient import session, exceptions
import uds_comms as uds
_usage = """This file is called from the keystone_utils.py file to implement
various keystone calls and functions. It is called with one parameter which is
the path to a Unix Domain Socket file.
The messages passed to the this process from the keystone_utils.py includes the
following keys:
{
'path': The api path on the keystone manager object.
'api_version': the keystone API version to use.
'api_local_endpoint': the local endpoint to connect to.
'admin_token': the admin token to use with keystone.
'args': the non-keyword argument to supply to the keystone manager call.
'kwargs': any keyword args to supply to the keystone manager call.
}
The result of the call, or an error, is returned as a json encoded result in
the same file that sent the arguments.
{
'result': <whatever the result of the function call was>
'error': <if an error occured, the text of the error
}
This system is currently needed to decouple the majority of the charm from the
underlying package being used for keystone.
"""
JSON_ENCODE_OPTIONS = dict(
sort_keys=True,
allow_nan=False,
indent=None,
separators=(',', ':'),
)
# Early versions of keystoneclient lib do not have an explicit
# ConnectionRefused
if hasattr(exceptions, 'ConnectionRefused'):
econnrefused = exceptions.ConnectionRefused
else:
econnrefused = exceptions.ConnectionError
def _get_keystone_manager_class(endpoint, token, api_version):
"""Return KeystoneManager class for the given API version
@param endpoint: the keystone endpoint to point client at
@param token: the keystone admin_token
@param api_version: version of the keystone api the client should use
@returns keystonemanager class used for interrogating keystone
"""
if api_version == 2:
return KeystoneManager2(endpoint, token)
if api_version == 3:
return KeystoneManager3(endpoint, token)
raise ValueError('No manager found for api version {}'.format(api_version))
def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
"""If the decorated function raises exception exc_type, allow num_retries
retry attempts before raise the exception.
"""
def _retry_on_exception_inner_1(f):
def _retry_on_exception_inner_2(*args, **kwargs):
retries = num_retries
multiplier = 1
while True:
try:
return f(*args, **kwargs)
except exc_type:
if not retries:
raise
delay = base_delay * multiplier
multiplier += 1
print("Retrying '%s' %d more times (delay={})"
.format(f.__name__, retries, delay))
retries -= 1
if delay:
time.sleep(delay)
return _retry_on_exception_inner_2
return _retry_on_exception_inner_1
@retry_on_exception(5, base_delay=3, exc_type=econnrefused)
def get_keystone_manager(endpoint, token, api_version=None):
"""Return a keystonemanager for the correct API version
If api_version has not been set then create a manager based on the endpoint
Use this manager to query the catalogue and determine which api version
should actually be being used. Return the correct client based on that.
Function is wrapped in a retry_on_exception to catch the case where the
keystone service is still initialising and not responding to requests yet.
XXX I think the keystone client should be able to do version
detection automatically so the code below could be greatly
simplified
@param endpoint: the keystone endpoint to point client at
@param token: the keystone admin_token
@param api_version: version of the keystone api the client should use
@returns keystonemanager class used for interrogating keystone
"""
if api_version:
return _get_keystone_manager_class(endpoint, token, api_version)
else:
if 'v2.0' in endpoint.split('/'):
manager = _get_keystone_manager_class(endpoint, token, 2)
else:
manager = _get_keystone_manager_class(endpoint, token, 3)
if endpoint.endswith('/'):
base_ep = endpoint.rsplit('/', 2)[0]
else:
base_ep = endpoint.rsplit('/', 1)[0]
svc_id = None
for svc in manager.api.services.list():
if svc.type == 'identity':
svc_id = svc.id
break
version = None
for ep in manager.api.endpoints.list():
if ep.service_id == svc_id and hasattr(ep, 'adminurl'):
version = ep.adminurl.split('/')[-1]
break
if version and version == 'v2.0':
new_ep = base_ep + "/" + 'v2.0'
return _get_keystone_manager_class(new_ep, token, 2)
elif version and version == 'v3':
new_ep = base_ep + "/" + 'v3'
return _get_keystone_manager_class(new_ep, token, 3)
else:
return manager
class KeystoneManager(object):
def resolved_api_version(self):
"""Used by keystone_utils.py to determine which endpoint template
to create based on the current endpoint which needs to actually be done
in get_keystone_manager() in this file.
:returns: the current api version
:rtype: int
"""
return self.api_version
def resolve_domain_id(self, name):
pass
def resolve_role_id(self, name):
"""Find the role_id of a given role"""
roles = [r._info for r in self.api.roles.list()]
for r in roles:
if name.lower() == r['name'].lower():
return r['id']
def resolve_service_id(self, name, service_type=None):
"""Find the service_id of a given service"""
services = [s._info for s in self.api.services.list()]
for s in services:
if service_type:
if (name.lower() == s['name'].lower() and
service_type == s['type']):
return s['id']
else:
if name.lower() == s['name'].lower():
return s['id']
def resolve_service_id_by_type(self, type):
"""Find the service_id of a given service"""
services = [s._info for s in self.api.services.list()]
for s in services:
if type == s['type']:
return s['id']
def delete_service_by_id(self, service_id):
"""Delete a service by the service id"""
self.api.services.delete(service_id)
def list_services(self):
"""Return a list of services (dictionary items)"""
return [s.to_dict() for s in self.api.services.list()]
def create_service(self, service_name, service_type, description):
"""Create a service using the api"""
self.api.services.create(service_name,
service_type,
description=description)
def list_endpoints(self):
"""Return a list of endpoints (dictionary items)"""
return [e.to_dict() for e in self.api.endpoints.list()]
def create_role(self, name):
"""Create the role by name."""
self.api.roles.create(name=name)
class KeystoneManager2(KeystoneManager):
def __init__(self, endpoint, token):
self.api_version = 2
self.api = client.Client(endpoint=endpoint, token=token)
def resolve_user_id(self, name, user_domain=None):
"""Find the user_id of a given user"""
users = [u._info for u in self.api.users.list()]
for u in users:
if name.lower() == u['name'].lower():
return u['id']
def create_endpoints(self, region, service_id, publicurl, adminurl,
internalurl):
self.api.endpoints.create(region=region, service_id=service_id,
publicurl=publicurl, adminurl=adminurl,
internalurl=internalurl)
def delete_endpoint_by_id(self, endpoint_id):
"""Delete an endpoint by the endpoint_id"""
self.api.endpoints.delete(endpoint_id)
def tenants_list(self):
return self.api.tenants.list()
def resolve_tenant_id(self, name, domain=None):
"""Find the tenant_id of a given tenant"""
tenants = [t._info for t in self.api.tenants.list()]
for t in tenants:
if name.lower() == t['name'].lower():
return t['id']
def create_tenant(self, tenant_name, description, domain='default'):
self.api.tenants.create(tenant_name=tenant_name,
description=description)
def delete_tenant(self, tenant_id):
self.api.tenants.delete(tenant_id)
def create_user(self, name, password, email, tenant_id=None,
domain_id=None):
self.api.users.create(name=name,
password=password,
email=email,
tenant_id=tenant_id)
def user_exists(self, name, domain=None):
if domain is not None:
raise ValueError("For keystone v2, domain cannot be set")
if self.resolve_user_id(name):
users = manager.api.users.list()
for user in users:
if user.name.lower() == name.lower():
return True
return False
def update_password(self, user, password):
self.api.users.update_password(user=user, password=password)
def roles_for_user(self, user_id, tenant_id=None, domain_id=None):
roles = self.api.roles.roles_for_user(user_id, tenant_id)
return [r.to_dict() for r in roles]
def add_user_role(self, user, role, tenant, domain):
self.api.roles.add_user_role(user=user, role=role, tenant=tenant)
class KeystoneManager3(KeystoneManager):
def __init__(self, endpoint, token):
self.api_version = 3
keystone_auth_v3 = token_endpoint.Token(endpoint=endpoint, token=token)
keystone_session_v3 = session.Session(auth=keystone_auth_v3)
self.api = keystoneclient_v3.Client(session=keystone_session_v3)
def resolve_tenant_id(self, name, domain=None):
"""Find the tenant_id of a given tenant"""
if domain:
domain_id = self.resolve_domain_id(domain)
tenants = [t._info for t in self.api.projects.list()]
for t in tenants:
if name.lower() == t['name'].lower() and \
(domain is None or t['domain_id'] == domain_id):
return t['id']
def resolve_domain_id(self, name):
"""Find the domain_id of a given domain"""
domains = [d._info for d in self.api.domains.list()]
for d in domains:
if name.lower() == d['name'].lower():
return d['id']
def resolve_user_id(self, name, user_domain=None):
"""Find the user_id of a given user"""
domain_id = None
if user_domain:
domain_id = self.resolve_domain_id(user_domain)
for user in self.api.users.list(domain=domain_id):
if name.lower() == user.name.lower():
if user_domain:
if domain_id == user.domain_id:
return user.id
else:
return user.id
def create_endpoints(self, region, service_id, publicurl, adminurl,
internalurl):
self.api.endpoints.create(service_id, publicurl, interface='public',
region=region)
self.api.endpoints.create(service_id, adminurl, interface='admin',
region=region)
self.api.endpoints.create(service_id, internalurl,
interface='internal', region=region)
def create_endpoint_by_type(self, service_id, endpoint, interface, region):
"""Create an endpoint by interface (type), where _interface is
'internal', 'admin' or 'public'.
"""
self.api.endpoints.create(
service_id, endpoint, interface=interface, region=region)
def tenants_list(self):
return self.api.projects.list()
def create_domain(self, domain_name, description):
self.api.domains.create(domain_name, description=description)
def create_tenant(self, tenant_name, description, domain='default'):
domain_id = self.resolve_domain_id(domain)
self.api.projects.create(tenant_name, domain_id,
description=description)
def delete_tenant(self, tenant_id):
self.api.projects.delete(tenant_id)
def create_user(self, name, password, email, tenant_id=None,
domain_id=None):
if not domain_id:
domain_id = self.resolve_domain_id('default')
if tenant_id:
self.api.users.create(name,
domain=domain_id,
password=password,
email=email,
project=tenant_id)
else:
self.api.users.create(name,
domain=domain_id,
password=password,
email=email)
def user_exists(self, name, domain=None):
domain_id = None
if domain:
domain_id = manager.resolve_domain_id(domain)
if not domain_id:
raise ValueError(
'Could not resolve domain_id for {} when checking if '
' user {} exists'.format(domain, name))
if manager.resolve_user_id(name, user_domain=domain):
users = manager.api.users.list(domain=domain_id)
for user in users:
if user.name.lower() == name.lower():
# In v3 Domains are seperate user namespaces so need to
# check that the domain matched if provided
if domain:
if domain_id == user.domain_id:
return True
else:
return True
return False
def update_password(self, user, password):
self.api.users.update(user, password=password)
def roles_for_user(self, user_id, tenant_id=None, domain_id=None):
# Specify either a domain or project, not both
if domain_id:
roles = self.api.roles.list(user_id, domain=domain_id)
else:
roles = self.api.roles.list(user_id, project=tenant_id)
return [r.to_dict() for r in roles]
def add_user_role(self, user, role, tenant, domain):
# Specify either a domain or project, not both
if domain:
self.api.roles.grant(role, user=user, domain=domain)
if tenant:
self.api.roles.grant(role, user=user, project=tenant)
def find_endpoint_v3(self, interface, service_id, region):
found_eps = []
for ep in self.api.endpoints.list():
if ep.service_id == service_id and ep.region == region and \
ep.interface == interface:
found_eps.append(ep)
return [e.to_dict() for e in found_eps]
def delete_old_endpoint_v3(self, interface, service_id, region, url):
eps = self.find_endpoint_v3(interface, service_id, region)
for ep in eps:
# if getattr(ep, 'url') != url:
if ep.get('url', None) != url:
# self.api.endpoints.delete(ep.id)
self.api.endpoints.delete(ep['id'])
return True
return False
# the following functions are proxied from keystone_utils, so that a Python3
# charm can work with a Python2 keystone_client (i.e. in the case of a snap
# installed payload
# used to provide a singleton if the credentials for the keystone_manager
# haven't changed.
_keystone_manager = dict(
api_version=None,
api_local_endpoint=None,
admin_token=None,
manager=None)
def get_manager(api_version=None, api_local_endpoint=None, admin_token=None):
"""Return a keystonemanager for the correct API version
This function actually returns a singleton of the right kind of
KeystoneManager (v2 or v3). If the api_version, api_local_endpoint and
admin_token haven't changed then the current _keystone_manager object is
returned, otherwise a new one is created (and thus the old one goes out of
scope and is closed). This is to that repeated calls to get_manager(...)
only results in a single authorisation request if the details don't change.
This is to speed up calls from the keystone charm into keystone and make
the charm more performant. It's hoped that the complexity/performance
trade-off is a good choice.
:param api_verion: The version of the api to use or None. if None then the
version is determined from the api_local_enpoint variable.
:param api_local_endpoint: where to find the keystone API
:param admin_token: the token used for authentication.
:raises: RuntimeError if api_local_endpoint or admin_token is not set.
:returns: a KeystoneManager derived class (possibly the singleton).
"""
if api_local_endpoint is None:
raise RuntimeError("get_manager(): api_local_endpoint is not set")
if admin_token is None:
raise RuntimeError("get_manager(): admin_token is not set")
global _keystone_manager
if (api_version == _keystone_manager['api_version'] and
api_local_endpoint == _keystone_manager['api_local_endpoint'] and
admin_token == _keystone_manager['admin_token']):
return _keystone_manager['manager']
# only retain the params IF getting the manager actually works
_keystone_manager['manager'] = get_keystone_manager(
api_local_endpoint, admin_token, api_version)
_keystone_manager['api_version'] = api_version
_keystone_manager['api_local_endpoint'] = api_local_endpoint
_keystone_manager['admin_token'] = admin_token
return _keystone_manager['manager']
class ManagerException(Exception):
pass
"""
In the following code, there is a slightly unusual construction:
_callable = manager
for attr in spec['path']:
_callable = getattr(_callable, attr)
What this does is allow the calling file to make it look like it was just
calling a deeply nested function in a class hierarchy.
So in the calling file, you get something like this:
manager = get_manager()
manager.some_function(a, b, c, y=10)
And that gets translated by the calling code into a json structure
that looks like:
{
"path": ['some_function'],
"args": [1, 2, 3],
"kwargs": {'y': 10},
... other bits for tokens, etc ...
}
If it was `manager.some_class.some_function(a, b, c, y=10)` then the "path"
would equal ['some_class', 'some_function'].
So what these three lines do is replicate the call on the KeystoneManager class
in this file, but successively grabbing attributes down/into the class using
the path as the attributes at each level.
"""
if __name__ == '__main__':
# This script needs 1 argument which is the unix domain socket though which
# it communicates with the caller. The program stays running until it is
# sent a 'STOP' command by the caller, or is just killed.
if len(sys.argv) != 2:
raise RuntimeError(
"{} called without 2 arguments: must pass the filename of the fifo"
.format(__file__))
filename = sys.argv[1]
if not stat.S_ISSOCK(os.stat(filename).st_mode):
raise RuntimeError(
"{} called with {} but it is not a Unix domain socket"
.format(__file__, filename))
uds_client = uds.UDSClient(filename)
uds_client.connect()
# endless loop whilst we process messages from the caller
while True:
try:
data = uds_client.receive()
if data == "QUIT":
break
spec = json.loads(data)
manager = get_manager(
api_version=spec['api_version'],
api_local_endpoint=spec['api_local_endpoint'],
admin_token=spec['admin_token'])
_callable = manager
for attr in spec['path']:
_callable = getattr(_callable, attr)
# now make the call and return the arguments
result = {'result': _callable(*spec['args'], **spec['kwargs'])}
except uds.UDSException as e:
print(str(e))
import traceback
traceback.print_exc()
try:
uds_client.close()
except Exception:
pass
sys.exit(1)
except ManagerException as e:
# deal with sending an error back.
print(str(e))
import traceback
traceback.print_exc()
result = {'error', str(e)}
except Exception as e:
print("{}: something went wrong: {}".format(__file__, str(e)))
import traceback
traceback.print_exc()
result = {'error': str(e)}
finally:
result_json = json.dumps(result, **JSON_ENCODE_OPTIONS)
uds_client.send(result_json)
# normal exit
exit(0)