Update libjuju
- fix licensing quirks
- refresh against libjuju master
Signed-off-by: Adam Israel <adam.israel@canonical.com>
diff --git a/modules/libjuju/juju/application.py b/modules/libjuju/juju/application.py
index 8719a62..620e9c9 100644
--- a/modules/libjuju/juju/application.py
+++ b/modules/libjuju/juju/application.py
@@ -1,3 +1,17 @@
+# 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.
+
import asyncio
import logging
@@ -342,8 +356,13 @@
raise ValueError("switch and revision are mutually exclusive")
client_facade = client.ClientFacade.from_connection(self.connection)
+ resources_facade = client.ResourcesFacade.from_connection(
+ self.connection)
app_facade = client.ApplicationFacade.from_connection(self.connection)
+ charmstore = self.model.charmstore
+ charmstore_entity = None
+
if switch is not None:
charm_url = switch
if not charm_url.startswith('cs:'):
@@ -354,18 +373,65 @@
if revision is not None:
charm_url = "%s-%d" % (charm_url, revision)
else:
- charmstore = self.model.charmstore
- entity = await charmstore.entity(charm_url, channel=channel)
- charm_url = entity['Id']
+ charmstore_entity = await charmstore.entity(charm_url,
+ channel=channel)
+ charm_url = charmstore_entity['Id']
if charm_url == self.data['charm-url']:
raise JujuError('already running charm "%s"' % charm_url)
+ # Update charm
await client_facade.AddCharm(
url=charm_url,
channel=channel
)
+ # Update resources
+ if not charmstore_entity:
+ charmstore_entity = await charmstore.entity(charm_url,
+ channel=channel)
+ store_resources = charmstore_entity['Meta']['resources']
+
+ request_data = [client.Entity(self.tag)]
+ response = await resources_facade.ListResources(request_data)
+ existing_resources = {
+ resource.name: resource
+ for resource in response.results[0].resources
+ }
+
+ resources_to_update = [
+ resource for resource in store_resources
+ if resource['Name'] not in existing_resources or
+ existing_resources[resource['Name']].origin != 'upload'
+ ]
+
+ if resources_to_update:
+ request_data = [
+ client.CharmResource(
+ description=resource.get('Description'),
+ fingerprint=resource['Fingerprint'],
+ name=resource['Name'],
+ path=resource['Path'],
+ revision=resource['Revision'],
+ size=resource['Size'],
+ type_=resource['Type'],
+ origin='store',
+ ) for resource in resources_to_update
+ ]
+ response = await resources_facade.AddPendingResources(
+ self.tag,
+ charm_url,
+ request_data
+ )
+ pending_ids = response.pending_ids
+ resource_ids = {
+ resource['Name']: id
+ for resource, id in zip(resources_to_update, pending_ids)
+ }
+ else:
+ resource_ids = None
+
+ # Update application
await app_facade.SetCharm(
application=self.entity_id,
channel=channel,
@@ -374,7 +440,7 @@
config_settings_yaml=None,
force_series=force_series,
force_units=force_units,
- resource_ids=None,
+ resource_ids=resource_ids,
storage_constraints=None
)
diff --git a/modules/libjuju/juju/client/_client.py b/modules/libjuju/juju/client/_client.py
index d510e11..2ef0ffd 100644
--- a/modules/libjuju/juju/client/_client.py
+++ b/modules/libjuju/juju/client/_client.py
@@ -1,7 +1,7 @@
# DO NOT CHANGE THIS FILE! This file is auto-generated by facade.py.
# Changes will be overwritten/lost when the file is regenerated.
-from juju.client._definitions import *
+from juju.client._definitions import * # noqa
from juju.client import _client1, _client2, _client3, _client4, _client5
@@ -15,22 +15,21 @@
}
-
def lookup_facade(name, version):
"""
Given a facade name and version, attempt to pull that facade out
of the correct client<version>.py file.
"""
- try:
- facade = getattr(CLIENTS[str(version)], name)
- except KeyError:
- raise ImportError("No facades found for version {}".format(version))
- except AttributeError:
- raise ImportError(
- "No facade with name '{}' in version {}".format(name, version))
- return facade
-
+ for _version in range(int(version), 0, -1):
+ try:
+ facade = getattr(CLIENTS[str(_version)], name)
+ return facade
+ except (KeyError, AttributeError):
+ continue
+ else:
+ raise ImportError("No supported version for facade: "
+ "{}".format(name))
class TypeFactory:
@@ -363,5 +362,3 @@
class VolumeAttachmentsWatcherFacade(TypeFactory):
pass
-
-
diff --git a/modules/libjuju/juju/client/client.py b/modules/libjuju/juju/client/client.py
index 89b5248..2f3e49d 100644
--- a/modules/libjuju/juju/client/client.py
+++ b/modules/libjuju/juju/client/client.py
@@ -5,7 +5,7 @@
for o in overrides.__all__:
- if not "Facade" in o:
+ if "Facade" not in o:
# Override stuff in _definitions, which is all imported
# into _client. We Monkey patch both the original class and
# the ref in _client (import shenanigans are fun!)
diff --git a/modules/libjuju/juju/client/connection.py b/modules/libjuju/juju/client/connection.py
index 7457391..c09468c 100644
--- a/modules/libjuju/juju/client/connection.py
+++ b/modules/libjuju/juju/client/connection.py
@@ -413,7 +413,7 @@
endpoints.extend(new_endpoints)
else:
# ran out of endpoints without a successful login
- raise Exception("Couldn't authenticate to {}".format(
+ raise JujuConnectionError("Couldn't authenticate to {}".format(
self._endpoint))
response = result['response']
@@ -584,6 +584,38 @@
def accounts(self):
return self._load_yaml('accounts.yaml', 'controllers')
+ def credentials(self):
+ return self._load_yaml('credentials.yaml', 'credentials')
+
+ def load_credential(self, cloud, name=None):
+ """Load a local credential.
+
+ :param str cloud: Name of cloud to load credentials from.
+ :param str name: Name of credential. If None, the default credential
+ will be used, if available.
+ :returns: A CloudCredential instance, or None.
+ """
+ try:
+ cloud = tag.untag('cloud-', cloud)
+ creds_data = self.credentials()[cloud]
+ if not name:
+ default_credential = creds_data.pop('default-credential', None)
+ default_region = creds_data.pop('default-region', None) # noqa
+ if default_credential:
+ name = creds_data['default-credential']
+ elif len(creds_data) == 1:
+ name = list(creds_data)[0]
+ else:
+ return None, None
+ cred_data = creds_data[name]
+ auth_type = cred_data.pop('auth-type')
+ return name, client.CloudCredential(
+ auth_type=auth_type,
+ attrs=cred_data,
+ )
+ except (KeyError, FileNotFoundError):
+ return None, None
+
def _load_yaml(self, filename, key):
filepath = os.path.join(self.path, filename)
with io.open(filepath, 'rt') as f:
diff --git a/modules/libjuju/juju/client/facade.py b/modules/libjuju/juju/client/facade.py
index c959e01..c015c5f 100644
--- a/modules/libjuju/juju/client/facade.py
+++ b/modules/libjuju/juju/client/facade.py
@@ -44,19 +44,19 @@
# Classes and helper functions that we'll write to _client.py
LOOKUP_FACADE = '''
def lookup_facade(name, version):
- """
- Given a facade name and version, attempt to pull that facade out
- of the correct client<version>.py file.
+ """
+ Given a facade name and version, attempt to pull that facade out
+ of the correct client<version>.py file.
- """
- try:
- facade = getattr(CLIENTS[str(version)], name)
- except KeyError:
- raise ImportError("No facades found for version {}".format(version))
- except AttributeError:
- raise ImportError(
- "No facade with name '{}' in version {}".format(name, version))
- return facade
+ """
+ try:
+ facade = getattr(CLIENTS[str(version)], name)
+ except KeyError:
+ raise ImportError("No facades found for version {}".format(version))
+ except AttributeError:
+ raise ImportError(
+ "No facade with name '{}' in version {}".format(name, version))
+ return facade
'''
@@ -127,6 +127,7 @@
return self[refname]
+
_types = TypeRegistry()
_registry = KindRegistry()
CLASSES = {}
@@ -257,7 +258,7 @@
for kind in sorted((k for k in _types if not isinstance(k, str)),
key=lambda x: str(x)):
name = _types[kind]
- if name in capture and not name in NAUGHTY_CLASSES:
+ if name in capture and name not in NAUGHTY_CLASSES:
continue
args = Args(kind)
# Write Factory class for _client.py
@@ -277,9 +278,7 @@
pprint.pformat(args.SchemaToPyMapping(), width=999),
", " if args else "",
args.as_kwargs(),
- textwrap.indent(args.get_doc(), INDENT * 2))
- ]
- assignments = args._get_arg_str(False, False)
+ textwrap.indent(args.get_doc(), INDENT * 2))]
if not args:
source.append("{}pass".format(INDENT * 2))
@@ -289,7 +288,9 @@
arg_type = arg[1]
arg_type_name = strcast(arg_type)
if arg_type in basic_types:
- source.append("{}self.{} = {}".format(INDENT * 2, arg_name, arg_name))
+ source.append("{}self.{} = {}".format(INDENT * 2,
+ arg_name,
+ arg_name))
elif issubclass(arg_type, typing.Sequence):
value_type = (
arg_type_name.__parameters__[0]
@@ -297,10 +298,16 @@
else None
)
if type(value_type) is typing.TypeVar:
- source.append("{}self.{} = [{}.from_json(o) for o in {} or []]".format(
- INDENT * 2, arg_name, strcast(value_type), arg_name))
+ source.append(
+ "{}self.{} = [{}.from_json(o) "
+ "for o in {} or []]".format(INDENT * 2,
+ arg_name,
+ strcast(value_type),
+ arg_name))
else:
- source.append("{}self.{} = {}".format(INDENT * 2, arg_name, arg_name))
+ source.append("{}self.{} = {}".format(INDENT * 2,
+ arg_name,
+ arg_name))
elif issubclass(arg_type, typing.Mapping):
value_type = (
arg_type_name.__parameters__[1]
@@ -308,15 +315,28 @@
else None
)
if type(value_type) is typing.TypeVar:
- source.append("{}self.{} = {{k: {}.from_json(v) for k, v in ({} or dict()).items()}}".format(
- INDENT * 2, arg_name, strcast(value_type), arg_name))
+ source.append(
+ "{}self.{} = {{k: {}.from_json(v) "
+ "for k, v in ({} or dict()).items()}}".format(
+ INDENT * 2,
+ arg_name,
+ strcast(value_type),
+ arg_name))
else:
- source.append("{}self.{} = {}".format(INDENT * 2, arg_name, arg_name))
+ source.append("{}self.{} = {}".format(INDENT * 2,
+ arg_name,
+ arg_name))
elif type(arg_type) is typing.TypeVar:
- source.append("{}self.{} = {}.from_json({}) if {} else None".format(
- INDENT * 2, arg_name, arg_type_name, arg_name, arg_name))
+ source.append("{}self.{} = {}.from_json({}) "
+ "if {} else None".format(INDENT * 2,
+ arg_name,
+ arg_type_name,
+ arg_name,
+ arg_name))
else:
- source.append("{}self.{} = {}".format(INDENT * 2, arg_name, arg_name))
+ source.append("{}self.{} = {}".format(INDENT * 2,
+ arg_name,
+ arg_name))
source = "\n".join(source)
capture.clear(name)
@@ -435,7 +455,10 @@
'''
# map input types to rpc msg
_params = dict()
- msg = dict(type='{cls.name}', request='{name}', version={cls.version}, params=_params)
+ msg = dict(type='{cls.name}',
+ request='{name}',
+ version={cls.version},
+ params=_params)
{assignments}
reply = {await}self.rpc(msg)
return reply
@@ -539,7 +562,7 @@
return d
def to_json(self):
- return json.dumps(self.serialize())
+ return json.dumps(self.serialize(), cls=TypeEncoder, sort_keys=True)
class Schema(dict):
@@ -576,7 +599,7 @@
if not defs:
return
for d, data in defs.items():
- if d in _registry and not d in NAUGHTY_CLASSES:
+ if d in _registry and d not in NAUGHTY_CLASSES:
continue
node = self.deref(data, d)
kind = node.get("type")
@@ -762,6 +785,7 @@
return captures
+
def setup():
parser = argparse.ArgumentParser()
parser.add_argument("-s", "--schema", default="juju/client/schemas*")
@@ -769,6 +793,7 @@
options = parser.parse_args()
return options
+
def main():
options = setup()
@@ -780,5 +805,6 @@
write_definitions(captures, options, last_version)
write_client(captures, options)
+
if __name__ == '__main__':
main()
diff --git a/modules/libjuju/juju/client/overrides.py b/modules/libjuju/juju/client/overrides.py
index f439adb..5e98e56 100644
--- a/modules/libjuju/juju/client/overrides.py
+++ b/modules/libjuju/juju/client/overrides.py
@@ -11,6 +11,7 @@
'Number',
'Binary',
'ConfigValue',
+ 'Resource',
]
__patches__ = [
@@ -273,3 +274,47 @@
return '<{} source={} value={}>'.format(type(self).__name__,
repr(self.source),
repr(self.value))
+
+
+class Resource(Type):
+ _toSchema = {'application': 'application',
+ 'charmresource': 'CharmResource',
+ 'id_': 'id',
+ 'pending_id': 'pending-id',
+ 'timestamp': 'timestamp',
+ 'username': 'username',
+ 'name': 'name',
+ 'origin': 'origin'}
+ _toPy = {'CharmResource': 'charmresource',
+ 'application': 'application',
+ 'id': 'id_',
+ 'pending-id': 'pending_id',
+ 'timestamp': 'timestamp',
+ 'username': 'username',
+ 'name': 'name',
+ 'origin': 'origin'}
+
+ def __init__(self, charmresource=None, application=None, id_=None,
+ pending_id=None, timestamp=None, username=None, name=None,
+ origin=None, **unknown_fields):
+ '''
+ charmresource : CharmResource
+ application : str
+ id_ : str
+ pending_id : str
+ timestamp : str
+ username : str
+ name: str
+ origin : str
+ '''
+ if charmresource:
+ self.charmresource = _client.CharmResource.from_json(charmresource)
+ else:
+ self.charmresource = None
+ self.application = application
+ self.id_ = id_
+ self.pending_id = pending_id
+ self.timestamp = timestamp
+ self.username = username
+ self.name = name
+ self.origin = origin
diff --git a/modules/libjuju/juju/client/runner.py b/modules/libjuju/juju/client/runner.py
index 61f2963..6545bc4 100644
--- a/modules/libjuju/juju/client/runner.py
+++ b/modules/libjuju/juju/client/runner.py
@@ -1,6 +1,4 @@
-
-
class AsyncRunner:
async def __call__(self, facade_method, *args, **kwargs):
await self.connection.rpc(facade_method(*args, **kwargs))
@@ -15,14 +13,9 @@
# This could let us fake the protocol we want
# while decoupling the protocol from the RPC and the IO/Process context
-# The problem is leaking the runtime impl details to the top levels of the API with
-# async def
-# By handling the Marshal/Unmarshal side of RPC as a protocol we can leave the RPC running to a specific
-# delegate without altering the method signatures.
-# This still isn't quite right though as async is co-op multitasking and the methods still need to know
-# not to block or they will pause other execution
-
-
-
-
-
+# The problem is leaking the runtime impl details to the top levels of the API
+# with async def By handling the Marshal/Unmarshal side of RPC as a protocol we
+# can leave the RPC running to a specific delegate without altering the method
+# signatures. This still isn't quite right though as async is co-op
+# multitasking and the methods still need to know not to block or they will
+# pause other execution
diff --git a/modules/libjuju/juju/controller.py b/modules/libjuju/juju/controller.py
index 9b452c7..55ea55e 100644
--- a/modules/libjuju/juju/controller.py
+++ b/modules/libjuju/juju/controller.py
@@ -1,11 +1,13 @@
import asyncio
import logging
+from . import errors
from . import tag
from . import utils
from .client import client
from .client import connection
from .model import Model
+from .user import User
log = logging.getLogger(__name__)
@@ -39,9 +41,11 @@
"""Connect to the current Juju controller.
"""
- self.connection = (
- await connection.Connection.connect_current_controller(
- max_frame_size=self.max_frame_size))
+ 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)
async def connect_controller(self, controller_name):
"""Connect to a Juju controller by name.
@@ -61,6 +65,47 @@
await self.connection.close()
self.connection = None
+ 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 = connection.JujuData().load_credential(cloud,
+ name)
+ if credential is None:
+ raise errors.JujuError('Unable to find credential: '
+ '{}'.format(name))
+
+ 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,
owner=None, config=None, region=None):
@@ -70,9 +115,8 @@
: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.
@@ -85,6 +129,16 @@
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 +150,11 @@
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.loop)
+
model_info = await model_facade.CreateModel(
tag.cloud(cloud_name),
config,
@@ -105,24 +164,6 @@
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 <key>` in the cli "
- "may fix this problem.")
-
model = Model()
await model.connect(
self.connection.endpoint,
@@ -136,24 +177,28 @@
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)
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 +206,26 @@
"""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)
+ 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.
@@ -231,17 +284,31 @@
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.
+ async def _model_uuids(self, all_=False, username=None):
+ 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.loop)
+
+ async def list_models(self, all_=False, username=None):
+ """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)
"""
- controller_facade = client.ControllerFacade.from_connection(
- self.connection)
- return await controller_facade.AllModels()
+ uuids = await self._model_uuids(all_, username)
+ return sorted(uuids.keys())
def get_payloads(self, *patterns):
"""Return list of known payloads.
@@ -261,14 +328,6 @@
"""
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,25 +343,62 @@
"""
raise NotImplementedError()
- def get_model(self, name):
- """Get a model by name.
+ async def get_model(self, model):
+ """Get a model by name or UUID.
- :param str name: Model name
+ :param str model: Model name or UUID
"""
- raise NotImplementedError()
+ uuids = await self._model_uuids()
+ if model in uuids:
+ name_or_uuid = uuids[model]
+ else:
+ name_or_uuid = model
- async def get_user(self, username, include_disabled=False):
+ 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,
+ )
+ return model
+
+ 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)
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.
+
+ :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'):
"""Set access level of the given user on the controller
diff --git a/modules/libjuju/juju/errors.py b/modules/libjuju/juju/errors.py
index de52174..ecd1c0d 100644
--- a/modules/libjuju/juju/errors.py
+++ b/modules/libjuju/juju/errors.py
@@ -1,5 +1,16 @@
class JujuError(Exception):
- pass
+ def __init__(self, *args, **kwargs):
+ self.message = ''
+ self.errors = []
+ if args:
+ self.message = str(args[0])
+ if isinstance(args[0], (list, tuple)):
+ self.errors = args[0]
+ elif len(args) > 1:
+ self.errors = list(args)
+ else:
+ self.errors = [self.message]
+ super().__init__(*args, **kwargs)
class JujuAPIError(JujuError):
diff --git a/modules/libjuju/juju/loop.py b/modules/libjuju/juju/loop.py
index 4abedfc..aca726b 100644
--- a/modules/libjuju/juju/loop.py
+++ b/modules/libjuju/juju/loop.py
@@ -24,7 +24,7 @@
try:
loop.add_signal_handler(signal.SIGINT, abort)
added = True
- except ValueError as e:
+ except (ValueError, OSError, RuntimeError) as e:
# add_signal_handler doesn't work in a thread
if 'main thread' not in str(e):
raise
diff --git a/modules/libjuju/juju/model.py b/modules/libjuju/juju/model.py
index bd8709a..fc8d5e9 100644
--- a/modules/libjuju/juju/model.py
+++ b/modules/libjuju/juju/model.py
@@ -1402,7 +1402,7 @@
key_facade = client.KeyManagerFacade.from_connection(self.connection)
key = base64.b64decode(bytes(key.strip().split()[1].encode('ascii')))
key = hashlib.md5(key).hexdigest()
- key = ':'.join(a+b for a, b in zip(key[::2], key[1::2]))
+ key = ':'.join(a + b for a, b in zip(key[::2], key[1::2]))
await key_facade.DeleteKeys([key], user)
remove_ssh_keys = remove_ssh_key
@@ -1658,8 +1658,9 @@
apps, args = [], []
default_series = bundle.get('series')
+ apps_dict = bundle.get('applications', bundle.get('services', {}))
for app_name in self.applications:
- app_dict = bundle['services'][app_name]
+ app_dict = apps_dict[app_name]
charm_dir = os.path.abspath(os.path.expanduser(app_dict['charm']))
if not os.path.isdir(charm_dir):
continue
@@ -1688,7 +1689,7 @@
], loop=self.model.loop)
# Update the 'charm:' entry for each app with the new 'local:' url.
for app_name, charm_url in zip(apps, charm_urls):
- bundle['services'][app_name]['charm'] = charm_url
+ apps_dict[app_name]['charm'] = charm_url
return bundle
@@ -1714,7 +1715,9 @@
@property
def applications(self):
- return list(self.bundle['services'].keys())
+ apps_dict = self.bundle.get('applications',
+ self.bundle.get('services', {}))
+ return list(apps_dict.keys())
def resolve(self, reference):
if reference and reference.startswith('$'):
diff --git a/modules/libjuju/juju/utils.py b/modules/libjuju/juju/utils.py
index 1d1b24e..1d9bc1c 100644
--- a/modules/libjuju/juju/utils.py
+++ b/modules/libjuju/juju/utils.py
@@ -11,11 +11,11 @@
'''
p = await asyncio.create_subprocess_exec(
- *cmd,
- stdin=asyncio.subprocess.PIPE,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- loop=loop)
+ *cmd,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ loop=loop)
stdout, stderr = await p.communicate()
if log:
log.debug("Exec %s -> %d", cmd, p.returncode)