# 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 keystoneauth1 import session as ks_session from keystoneauth1.identity import v2 as ks_identity_v2 from keystoneauth1.identity import v3 as ks_identity_v3 from keystoneclient.v2_0 import client as keystoneclient_v2 from keystoneclient.v3 import client as keystoneclient_v3 from keystoneclient import exceptions import uds_comms as uds import keystone_types _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. 'charm_credentials': the credentials to use when speaking 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': 'error': 'id': } One of either the :param:`domain` or :param:`domain_id` must be supplied or otherwise the function raises a RuntimeError. :param domain: The domain name. :type domain: Optional[str] :param domain_id: The domain_id string :type domain_id: Optional[str] :returns: a list of user dictionaries in the domain :rtype: List[Dict[str, ANY]] :raises: RuntimeError if no domain or domain_id is passed. ValueError if the domain_id cannot be resolved from the domain """ if domain is None and domain_id is None: raise RuntimeError("Must supply either domain or domain_id param") domain_id = domain_id or manager.resolve_domain_id(domain) if domain_id is None: raise ValueError( 'Could not resolve domain_id for {}.'.format(domain)) users = [{'name': u.name, 'id': u.id} for u in self.api.users.list(domain=domain_id)] return users 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, charm_credentials=None, manager=None) def get_manager(api_version=None, api_local_endpoint=None, charm_credentials=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 charm_credentials 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 charm_credentials: the credentials used for authentication. :raises: RuntimeError if api_local_endpoint or charm_credentials 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 charm_credentials is None: raise RuntimeError("get_manager(): charm_credentials is not set") global _keystone_manager if (api_version == _keystone_manager['api_version'] and api_local_endpoint == _keystone_manager['api_local_endpoint'] and charm_credentials == _keystone_manager['charm_credentials']): return _keystone_manager['manager'] # only retain the params IF getting the manager actually works _keystone_manager['manager'] = get_keystone_manager( api_local_endpoint, charm_credentials, api_version) _keystone_manager['api_version'] = api_version _keystone_manager['api_local_endpoint'] = api_local_endpoint _keystone_manager['charm_credentials'] = charm_credentials 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: result = None data = uds_client.receive() if data == "QUIT" or data is None: break spec = json.loads(data) manager = get_manager( api_version=spec['api_version'], api_local_endpoint=spec['api_local_endpoint'], charm_credentials=keystone_types.CharmCredentials._make( spec['charm_credentials'])) _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 exceptions.InternalServerError as e: # we've hit a 500 error, which is bad, and really we want the # parent process to restart us to try again. print(str(e)) result = {'error': str(e), 'retry': True} 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: if result is not None: result_json = json.dumps(result, **JSON_ENCODE_OPTIONS) uds_client.send(result_json) # normal exit exit(0)