X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FN2VC.git;a=blobdiff_plain;f=modules%2Flibjuju%2Fjuju%2Fcontroller.py;h=b4c544edab5633350e1dcec82bd92da9084f4070;hp=9b452c7c88b0fa3c086d3263217da3973dc08674;hb=1afb30a22cc175cf67572b7195609be6a484258c;hpb=68858c1915122c2dbc8999a5cd3229694abf5f3a diff --git a/modules/libjuju/juju/controller.py b/modules/libjuju/juju/controller.py index 9b452c7..b4c544e 100644 --- a/modules/libjuju/juju/controller.py +++ b/modules/libjuju/juju/controller.py @@ -1,65 +1,227 @@ import asyncio +import json import logging +from pathlib import Path -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. - """ - self.connection = ( - await connection.Connection.connect_current_controller( - max_frame_size=self.max_frame_size)) + .. 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): + """Add or update a credential to the controller. + + :param str name: Name of new credential. If None, the default + local credential is used. Name must be provided if a credential + is given. + :param CloudCredential credential: Credential to add. If not given, + it will attempt to read from local data, if available. + :param str cloud: Name of cloud to associate the credential with. + Defaults to the same cloud as the controller. + :param str owner: Username that will own the credential. Defaults to + the current user. + :returns: Name of credential that was uploaded. + """ + if not cloud: + cloud = await self.get_cloud() + + if not owner: + 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 = self._connector.jujudata.load_credential(cloud, name) + if credential is None: + 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()) + await cloud_facade.UpdateCredentials([ + client.UpdateCloudCredential( + tag=tag.credential(cloud, tag.untag('user-', owner), name), + credential=credential, + )]) + + return name async def add_model( self, model_name, cloud_name=None, credential_name=None, @@ -70,21 +232,30 @@ class Controller(object): :param str cloud_name: Name of the cloud in which to create the model, e.g. 'aws'. Defaults to same cloud as controller. :param str credential_name: Name of the credential to use when - creating the model. Defaults to current credential. If you - pass a credential_name, you must also pass a cloud_name, - even if it's the default cloud. + creating the model. If not given, it will attempt to find a + default credential. :param str owner: Username that will own the model. Defaults to 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: + # attempt to add/update the credential from local data if available + credential_name = await self.add_credential( + name=credential_name, + cloud=cloud_name, + owner=owner) + except errors.JujuError: + # if it's not available locally, assume it's on the controller + pass + if credential_name: credential = tag.credential( cloud_name, @@ -96,6 +267,11 @@ class Controller(object): log.debug('Creating model %s', model_name) + if not config or 'authorized-keys' not in config: + config = config or {} + config['authorized-keys'] = await utils.read_ssh_key( + loop=self._connector.loop) + model_info = await model_facade.CreateModel( tag.cloud(cloud_name), config, @@ -104,56 +280,36 @@ class Controller(object): owner, region ) - - # Add our ssh key to the model, to work around - # https://bugs.launchpad.net/juju/+bug/1643076 - try: - ssh_key = await utils.read_ssh_key(loop=self.loop) - - if self.controller_name: - model_name = "{}:{}".format(self.controller_name, model_name) - - cmd = ['juju', 'add-ssh-key', '-m', model_name, ssh_key] - - await utils.execute_process(*cmd, log=log, loop=self.loop) - except Exception: - log.exception( - "Could not add ssh key to model. You will not be able " - "to ssh into machines in this model. " - "Manually running `juju add-ssh-key ` in the cli " - "may fix this problem.") - - 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 - async def destroy_models(self, *uuids): + async def destroy_models(self, *models): """Destroy one or more models. - :param str \*uuids: UUIDs of models to destroy + :param str \*models: Names or UUIDs of models to destroy """ + 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', - '' if len(uuids) == 1 else 's', - ', '.join(uuids) + '' if len(models) == 1 else 's', + ', '.join(models) ) await model_facade.DestroyModels([ - client.Entity(tag.model(uuid)) - for uuid in uuids + client.Entity(tag.model(model)) + for model in models ]) destroy_model = destroy_models @@ -161,18 +317,26 @@ class Controller(object): """Add a user to this controller. :param str username: Username + :param str password: Password :param str display_name: Display name - :param str acl: Access control, e.g. 'read' - :param list models: Models to which the user is granted access - + :returns: A :class:`~juju.user.User` instance """ if not display_name: display_name = username - user_facade = client.UserManagerFacade.from_connection(self.connection) - users = [{'display_name': display_name, - 'password': password, - 'username': username}] - return await user_facade.AddUser(users) + user_facade = client.UserManagerFacade.from_connection(self.connection()) + users = [client.AddUser(display_name=display_name, + username=username, + password=password)] + await user_facade.AddUser(users) + return await self.get_user(username) + + async def remove_user(self, username): + """Remove a user from this controller. + """ + client_facade = client.UserManagerFacade.from_connection( + self.connection()) + user = tag.user(username) + await client_facade.RemoveUser([client.Entity(user)]) async def change_user_password(self, username, password): """Change the password for a user in this controller. @@ -181,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]) @@ -193,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): @@ -202,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]) @@ -210,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]) @@ -225,23 +389,52 @@ 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 get_models(self, all_=False, username=None): - """Return list of 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) - + """ + .. deprecated:: 0.7.0 + Use :meth:`.list_models` instead. """ controller_facade = client.ControllerFacade.from_connection( - self.connection) - return await controller_facade.AllModels() + 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() + return {um.model.name: um.model.uuid + for um in response.user_models} + 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 + await asyncio.sleep(attempt, loop=self._connector.loop) + + async def list_models(self): + """Return list of names of the available models on this controller. + + Equivalent to ``sorted((await self.model_uuids()).keys())`` + """ + uuids = await self.model_uuids() + return sorted(uuids.keys()) def get_payloads(self, *patterns): """Return list of known payloads. @@ -261,14 +454,6 @@ class Controller(object): """ raise NotImplementedError() - def get_users(self, all_=False): - """Return list of users that can connect to this controller. - - :param bool all_: Include disabled users - - """ - raise NotImplementedError() - def login(self): """Log in to this controller. @@ -284,48 +469,125 @@ class Controller(object): """ raise NotImplementedError() - def get_model(self, name): - """Get a model by name. - - :param str name: Model name + async def get_model(self, model): + """Get a model by name or UUID. + :param str model: Model name or UUID + :returns Model: Connected Model instance. """ - raise NotImplementedError() + uuids = await self.model_uuids() + if model in uuids: + uuid = uuids[model] + else: + uuid = model + + from juju.model import Model + model = Model() + kwargs = self.connection().connect_params() + kwargs['uuid'] = uuid + await model._connect_direct(**kwargs) + return model - async def get_user(self, username, include_disabled=False): + async def get_user(self, username): """Get a user by name. :param str username: Username - + :returns: A :class:`~juju.user.User` instance """ client_facade = client.UserManagerFacade.from_connection( - self.connection) + self.connection()) user = tag.user(username) - return await client_facade.UserInfo([client.Entity(user)], - include_disabled) + args = [client.Entity(user)] + try: + response = await client_facade.UserInfo(args, True) + except errors.JujuError as e: + if 'permission denied' in e.errors: + # apparently, trying to get info for a nonexistent user returns + # a "permission denied" error rather than an empty result set + return None + raise + if response.results and response.results[0].result: + return User(self, response.results[0].result) + return None + + async def get_users(self, include_disabled=False): + """Return list of users that can connect to this controller. - async def grant(self, username, acl='login'): - """Set access level of the given user on the controller + :param bool include_disabled: Include disabled users + :returns: A list of :class:`~juju.user.User` instances + """ + client_facade = client.UserManagerFacade.from_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'): + """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]) - - async def revoke(self, username): - """Removes all access from a controller + 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, 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])