X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FN2VC.git;a=blobdiff_plain;f=modules%2Flibjuju%2Fjuju%2Fcontroller.py;h=b4c544edab5633350e1dcec82bd92da9084f4070;hp=55ea55e979f80387b475eced23fd5e36d715ed5b;hb=1afb30a22cc175cf67572b7195609be6a484258c;hpb=1a15d1c84fc826fa7996c1c9d221a324edd33432 diff --git a/modules/libjuju/juju/controller.py b/modules/libjuju/juju/controller.py index 55ea55e..b4c544e 100644 --- a/modules/libjuju/juju/controller.py +++ b/modules/libjuju/juju/controller.py @@ -1,69 +1,172 @@ import asyncio +import json import logging +from pathlib import Path -from . import errors -from . import tag -from . import utils -from .client import client -from .client import connection -from .model import Model +from . import errors, tag, utils +from .client import client, connector from .user import User log = logging.getLogger(__name__) -class Controller(object): - def __init__(self, loop=None, - max_frame_size=connection.Connection.DEFAULT_FRAME_SIZE): +class Controller: + def __init__( + self, + loop=None, + max_frame_size=None, + bakery_client=None, + jujudata=None, + ): """Instantiate a new Controller. One of the connect_* methods will need to be called before this object can be used for anything interesting. - :param loop: an asyncio event loop + If jujudata is None, jujudata.FileJujuData will be used. + :param loop: an asyncio event loop + :param max_frame_size: See + `juju.client.connection.Connection.MAX_FRAME_SIZE` + :param bakery_client httpbakery.Client: The bakery client to use + for macaroon authorization. + :param jujudata JujuData: The source for current controller information. """ - self.loop = loop or asyncio.get_event_loop() - self.max_frame_size = None - self.connection = None - self.controller_name = None - - async def connect( - self, endpoint, username, password, cacert=None, macaroons=None): - """Connect to an arbitrary Juju controller. + self._connector = connector.Connector( + loop=loop, + max_frame_size=max_frame_size, + bakery_client=bakery_client, + jujudata=jujudata, + ) + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.disconnect() + + @property + def loop(self): + return self._connector.loop + + async def connect(self, *args, **kwargs): + """Connect to a Juju controller. + + This supports two calling conventions: + + The controller and (optionally) authentication information can be + taken from the data files created by the Juju CLI. This convention + will be used if a ``controller_name`` is specified, or if the + ``endpoint`` is not. + + Otherwise, both the ``endpoint`` and authentication information + (``username`` and ``password``, or ``bakery_client`` and/or + ``macaroons``) are required. + + If a single positional argument is given, it will be assumed to be + the ``controller_name``. Otherwise, the first positional argument, + if any, must be the ``endpoint``. + + Available parameters are: + + :param str controller_name: Name of controller registered with the + Juju CLI. + :param str endpoint: The hostname:port of the controller to connect to. + :param str username: The username for controller-local users (or None + to use macaroon-based login.) + :param str password: The password for controller-local users. + :param str cacert: The CA certificate of the controller + (PEM formatted). + :param httpbakery.Client bakery_client: The macaroon bakery client to + to use when performing macaroon-based login. Macaroon tokens + acquired when logging will be saved to bakery_client.cookies. + If this is None, a default bakery_client will be used. + :param list macaroons: List of macaroons to load into the + ``bakery_client``. + :param asyncio.BaseEventLoop loop: The event loop to use for async + operations. + :param int max_frame_size: The maximum websocket frame size to allow. """ - self.connection = await connection.Connection.connect( - endpoint, None, username, password, cacert, macaroons, - max_frame_size=self.max_frame_size) + await self.disconnect() + if 'endpoint' not in kwargs and len(args) < 2: + if args and 'model_name' in kwargs: + raise TypeError('connect() got multiple values for ' + 'controller_name') + elif args: + controller_name = args[0] + else: + controller_name = kwargs.pop('controller_name', None) + await self._connector.connect_controller(controller_name, **kwargs) + else: + if 'controller_name' in kwargs: + raise TypeError('connect() got values for both ' + 'controller_name and endpoint') + if args and 'endpoint' in kwargs: + raise TypeError('connect() got multiple values for endpoint') + has_userpass = (len(args) >= 3 or + {'username', 'password'}.issubset(kwargs)) + has_macaroons = (len(args) >= 5 or not + {'bakery_client', 'macaroons'}.isdisjoint(kwargs)) + if not (has_userpass or has_macaroons): + raise TypeError('connect() missing auth params') + arg_names = [ + 'endpoint', + 'username', + 'password', + 'cacert', + 'bakery_client', + 'macaroons', + 'loop', + 'max_frame_size', + ] + for i, arg in enumerate(args): + kwargs[arg_names[i]] = arg + if 'endpoint' not in kwargs: + raise ValueError('endpoint is required ' + 'if controller_name not given') + if not ({'username', 'password'}.issubset(kwargs) or + {'bakery_client', 'macaroons'}.intersection(kwargs)): + raise ValueError('Authentication parameters are required ' + 'if controller_name not given') + await self._connector.connect(**kwargs) async def connect_current(self): - """Connect to the current Juju controller. - """ - jujudata = connection.JujuData() - controller_name = jujudata.current_controller() - if not controller_name: - raise errors.JujuConnectionError('No current controller') - return await self.connect_controller(controller_name) + .. deprecated:: 0.7.3 + Use :meth:`.connect()` instead. + """ + return await self.connect() async def connect_controller(self, controller_name): - """Connect to a Juju controller by name. - """ - self.connection = ( - await connection.Connection.connect_controller( - controller_name, max_frame_size=self.max_frame_size)) - self.controller_name = controller_name + .. deprecated:: 0.7.3 + Use :meth:`.connect(controller_name)` instead. + """ + return await self.connect(controller_name) + + async def _connect_direct(self, **kwargs): + await self.disconnect() + await self._connector.connect(**kwargs) + + def is_connected(self): + """Reports whether the Controller is currently connected.""" + return self._connector.is_connected() + + def connection(self): + """Return the current Connection object. It raises an exception + if the Controller is disconnected""" + return self._connector.connection() + + @property + def controller_name(self): + return self._connector.controller_name async def disconnect(self): """Shut down the watcher task and close websockets. """ - if self.connection and self.connection.is_open: - log.debug('Closing controller connection') - await self.connection.close() - self.connection = None + await self._connector.disconnect() async def add_credential(self, name=None, credential=None, cloud=None, owner=None): @@ -84,20 +187,34 @@ class Controller(object): cloud = await self.get_cloud() if not owner: - owner = self.connection.info['user-info']['identity'] + owner = self.connection().info['user-info']['identity'] if credential and not name: raise errors.JujuError('Name must be provided for credential') if not credential: - name, credential = connection.JujuData().load_credential(cloud, - name) + name, credential = self._connector.jujudata.load_credential(cloud, name) if credential is None: - raise errors.JujuError('Unable to find credential: ' - '{}'.format(name)) + raise errors.JujuError( + 'Unable to find credential: {}'.format(name)) + + if credential.auth_type == 'jsonfile' and 'file' in credential.attrs: + # file creds have to be loaded before being sent to the controller + try: + # it might already be JSON + json.loads(credential.attrs['file']) + except json.JSONDecodeError: + # not valid JSON, so maybe it's a file + cred_path = Path(credential.attrs['file']) + if cred_path.exists(): + # make a copy + cred_json = credential.to_json() + credential = client.CloudCredential.from_json(cred_json) + # inline the cred + credential.attrs['file'] = cred_path.read_text() log.debug('Uploading credential %s', name) - cloud_facade = client.CloudFacade.from_connection(self.connection) + cloud_facade = client.CloudFacade.from_connection(self.connection()) await cloud_facade.UpdateCredentials([ client.UpdateCloudCredential( tag=tag.credential(cloud, tag.untag('user-', owner), name), @@ -121,12 +238,12 @@ class Controller(object): the current user. :param dict config: Model configuration. :param str region: Region in which to create the model. - + :return Model: A connection to the newly created model. """ model_facade = client.ModelManagerFacade.from_connection( - self.connection) + self.connection()) - owner = owner or self.connection.info['user-info']['identity'] + owner = owner or self.connection().info['user-info']['identity'] cloud_name = cloud_name or await self.get_cloud() try: @@ -153,7 +270,7 @@ class Controller(object): if not config or 'authorized-keys' not in config: config = config or {} config['authorized-keys'] = await utils.read_ssh_key( - loop=self.loop) + loop=self._connector.loop) model_info = await model_facade.CreateModel( tag.cloud(cloud_name), @@ -163,17 +280,11 @@ class Controller(object): owner, region ) - - model = Model() - await model.connect( - self.connection.endpoint, - model_info.uuid, - self.connection.username, - self.connection.password, - self.connection.cacert, - self.connection.macaroons, - loop=self.loop, - ) + from juju.model import Model + model = Model(jujudata=self._connector.jujudata) + kwargs = self.connection().connect_params() + kwargs['uuid'] = model_info.uuid + await model._connect_direct(**kwargs) return model @@ -183,12 +294,12 @@ class Controller(object): :param str \*models: Names or UUIDs of models to destroy """ - uuids = await self._model_uuids() + uuids = await self.model_uuids() models = [uuids[model] if model in uuids else model for model in models] model_facade = client.ModelManagerFacade.from_connection( - self.connection) + self.connection()) log.debug( 'Destroying model%s %s', @@ -212,7 +323,7 @@ class Controller(object): """ if not display_name: display_name = username - user_facade = client.UserManagerFacade.from_connection(self.connection) + user_facade = client.UserManagerFacade.from_connection(self.connection()) users = [client.AddUser(display_name=display_name, username=username, password=password)] @@ -223,7 +334,7 @@ class Controller(object): """Remove a user from this controller. """ client_facade = client.UserManagerFacade.from_connection( - self.connection) + self.connection()) user = tag.user(username) await client_facade.RemoveUser([client.Entity(user)]) @@ -234,7 +345,7 @@ class Controller(object): :param str password: New password """ - user_facade = client.UserManagerFacade.from_connection(self.connection) + user_facade = client.UserManagerFacade.from_connection(self.connection()) entity = client.EntityPassword(password, tag.user(username)) return await user_facade.SetPassword([entity]) @@ -246,7 +357,7 @@ class Controller(object): """ controller_facade = client.ControllerFacade.from_connection( - self.connection) + self.connection()) return await controller_facade.DestroyController(destroy_all_models) async def disable_user(self, username): @@ -255,7 +366,7 @@ class Controller(object): :param str username: Username """ - user_facade = client.UserManagerFacade.from_connection(self.connection) + user_facade = client.UserManagerFacade.from_connection(self.connection()) entity = client.Entity(tag.user(username)) return await user_facade.DisableUser([entity]) @@ -263,7 +374,7 @@ class Controller(object): """Re-enable a previously disabled user. """ - user_facade = client.UserManagerFacade.from_connection(self.connection) + user_facade = client.UserManagerFacade.from_connection(self.connection()) entity = client.Entity(tag.user(username)) return await user_facade.EnableUser([entity]) @@ -278,15 +389,33 @@ class Controller(object): """ Get the name of the cloud that this controller lives on. """ - cloud_facade = client.CloudFacade.from_connection(self.connection) + cloud_facade = client.CloudFacade.from_connection(self.connection()) result = await cloud_facade.Clouds() cloud = list(result.clouds.keys())[0] # only lives on one cloud return tag.untag('cloud-', cloud) - async def _model_uuids(self, all_=False, username=None): + async def get_models(self, all_=False, username=None): + """ + .. deprecated:: 0.7.0 + Use :meth:`.list_models` instead. + """ controller_facade = client.ControllerFacade.from_connection( - self.connection) + self.connection()) + for attempt in (1, 2, 3): + try: + return await controller_facade.AllModels() + except errors.JujuAPIError as e: + # retry concurrency error until resolved in Juju + # see: https://bugs.launchpad.net/juju/+bug/1721786 + if 'has been removed' not in e.message or attempt == 3: + raise + + async def model_uuids(self): + """Return a mapping of model names to UUIDs. + """ + controller_facade = client.ControllerFacade.from_connection( + self.connection()) for attempt in (1, 2, 3): try: response = await controller_facade.AllModels() @@ -297,17 +426,14 @@ class Controller(object): # see: https://bugs.launchpad.net/juju/+bug/1721786 if 'has been removed' not in e.message or attempt == 3: raise - await asyncio.sleep(attempt, loop=self.loop) + await asyncio.sleep(attempt, loop=self._connector.loop) - async def list_models(self, all_=False, username=None): + async def list_models(self): """Return list of names of the available models on this controller. - :param bool all_: List all models, regardless of user accessibilty - (admin use only) - :param str username: User for which to list models (admin use only) - + Equivalent to ``sorted((await self.model_uuids()).keys())`` """ - uuids = await self._model_uuids(all_, username) + uuids = await self.model_uuids() return sorted(uuids.keys()) def get_payloads(self, *patterns): @@ -347,24 +473,19 @@ class Controller(object): """Get a model by name or UUID. :param str model: Model name or UUID - + :returns Model: Connected Model instance. """ - uuids = await self._model_uuids() + uuids = await self.model_uuids() if model in uuids: - name_or_uuid = uuids[model] + uuid = uuids[model] else: - name_or_uuid = model + uuid = model + from juju.model import Model model = Model() - await model.connect( - self.connection.endpoint, - name_or_uuid, - self.connection.username, - self.connection.password, - self.connection.cacert, - self.connection.macaroons, - loop=self.loop, - ) + kwargs = self.connection().connect_params() + kwargs['uuid'] = uuid + await model._connect_direct(**kwargs) return model async def get_user(self, username): @@ -374,7 +495,7 @@ class Controller(object): :returns: A :class:`~juju.user.User` instance """ client_facade = client.UserManagerFacade.from_connection( - self.connection) + self.connection()) user = tag.user(username) args = [client.Entity(user)] try: @@ -396,32 +517,77 @@ class Controller(object): :returns: A list of :class:`~juju.user.User` instances """ client_facade = client.UserManagerFacade.from_connection( - self.connection) + self.connection()) response = await client_facade.UserInfo(None, include_disabled) return [User(self, r.result) for r in response.results] async def grant(self, username, acl='login'): - """Set access level of the given user on the controller - + """Grant access level of the given user on the controller. + Note that if the user already has higher permissions than the + provided ACL, this will do nothing (see revoke for a way to + remove permissions). :param str username: Username :param str acl: Access control ('login', 'add-model' or 'superuser') - + :returns: True if new access was granted, False if user already had + requested access or greater. Raises JujuError if failed. """ controller_facade = client.ControllerFacade.from_connection( - self.connection) + self.connection()) user = tag.user(username) - await self.revoke(username) changes = client.ModifyControllerAccess(acl, 'grant', user) - return await controller_facade.ModifyControllerAccess([changes]) + try: + await controller_facade.ModifyControllerAccess([changes]) + return True + except errors.JujuError as e: + if 'user already has' in str(e): + return False + else: + raise - async def revoke(self, username): - """Removes all access from a controller + async def revoke(self, username, acl='login'): + """Removes some or all access of a user to from a controller + If 'login' access is revoked, the user will no longer have any + permissions on the controller. Revoking a higher privilege from + a user without that privilege will have no effect. :param str username: username - + :param str acl: Access to remove ('login', 'add-model' or 'superuser') """ controller_facade = client.ControllerFacade.from_connection( - self.connection) + self.connection()) user = tag.user(username) changes = client.ModifyControllerAccess('login', 'revoke', user) return await controller_facade.ModifyControllerAccess([changes]) + + async def grant_model(self, username, model_uuid, acl='read'): + """Grant a user access to a model. Note that if the user + already has higher permissions than the provided ACL, + this will do nothing (see revoke_model for a way to remove permissions). + + :param str username: Username + :param str model_uuid: The UUID of the model to change. + :param str acl: Access control ('read, 'write' or 'admin') + """ + model_facade = client.ModelManagerFacade.from_connection( + self.connection()) + user = tag.user(username) + model = tag.model(model_uuid) + changes = client.ModifyModelAccess(acl, 'grant', model, user) + return await model_facade.ModifyModelAccess([changes]) + + async def revoke_model(self, username, model_uuid, acl='read'): + """Revoke some or all of a user's access to a model. + If 'read' access is revoked, the user will no longer have any + permissions on the model. Revoking a higher privilege from + a user without that privilege will have no effect. + + :param str username: Username to revoke + :param str model_uuid: The UUID of the model to change. + :param str acl: Access control ('read, 'write' or 'admin') + """ + model_facade = client.ModelManagerFacade.from_connection( + self.connection()) + user = tag.user(username) + model = tag.model(self.info.uuid) + changes = client.ModifyModelAccess(acl, 'revoke', model, user) + return await model_facade.ModifyModelAccess([changes])