+# Copyright 2019 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 json
import logging
+from pathlib import Path
from . import errors, tag, utils
from .client import client, connector
`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.
+ :param jujudata JujuData: The source for current controller
+ information.
"""
self._connector = connector.Connector(
loop=loop,
def loop(self):
return self._connector.loop
- async def connect(self, controller_name=None, **kwargs):
+ async def connect(self, *args, **kwargs):
"""Connect to a Juju controller.
- If any arguments are specified other than controller_name,
- then controller_name must be None and an explicit
- connection will be made using Connection.connect
- using those parameters (the 'uuid' parameter must
- be absent or None).
-
- Otherwise, if controller_name is None, connect to the
- current controller.
-
- Otherwise, controller_name must specify the name
- of a known 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.
"""
await self.disconnect()
- if not kwargs:
- await self._connector.connect_controller(controller_name)
+ 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 is not None:
- raise ValueError('controller name may not be specified with other connect parameters')
- if kwargs.get('uuid') is not None:
- # A UUID implies a model connection, not a controller connection.
- raise ValueError('model UUID specified when connecting to controller')
+ 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):
+ """
+ .. deprecated:: 0.7.3
+ Use :meth:`.connect()` instead.
+ """
+ return await self.connect()
+
+ async def connect_controller(self, 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)
await self._connector.disconnect()
async def add_credential(self, name=None, credential=None, cloud=None,
- owner=None):
+ owner=None, force=False):
"""Add or update a credential to the controller.
:param str name: Name of new credential. If None, the default
Defaults to the same cloud as the controller.
:param str owner: Username that will own the credential. Defaults to
the current user.
+ :param bool force: Force indicates whether the update should be forced.
+ It's only supported for facade v3 or later.
+ Defaults to false.
:returns: Name of credential that was uploaded.
"""
if not cloud:
raise errors.JujuError('Name must be provided for credential')
if not credential:
- name, credential = self._connector.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))
+ 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([
+ tagged_credentials = [
client.UpdateCloudCredential(
tag=tag.credential(cloud, tag.untag('user-', owner), name),
credential=credential,
- )])
-
+ )]
+ if cloud_facade.version >= 3:
+ # UpdateCredentials was renamed to UpdateCredentialsCheckModels
+ # in facade version 3.
+ await cloud_facade.UpdateCredentialsCheckModels(
+ credentials=tagged_credentials, force=force,
+ )
+ else:
+ await cloud_facade.UpdateCredentials(tagged_credentials)
return name
async def add_model(
owner,
region
)
+
+ # This is a temporary workaround for a race condition:
+ # https://bugs.launchpad.net/juju/+bug/1838774
+ # This will be fixed when Juju 2.6.7 is released.
+ import time
+ time.sleep(5)
+
from juju.model import Model
model = Model(jujudata=self._connector.jujudata)
kwargs = self.connection().connect_params()
return model
- async def destroy_models(self, *models):
+ async def destroy_models(self, *models, destroy_storage=False):
"""Destroy one or more models.
- :param str \*models: Names or UUIDs of models to destroy
+ :param str *models: Names or UUIDs of models to destroy
+ :param bool destroy_storage: Whether or not to destroy storage when
+ destroying the models. Defaults to false.
"""
uuids = await self.model_uuids()
', '.join(models)
)
- await model_facade.DestroyModels([
- client.Entity(tag.model(model))
- for model in models
- ])
+ if model_facade.version >= 5:
+ params = [
+ client.DestroyModelParams(model_tag=tag.model(model),
+ destroy_storage=destroy_storage)
+ for model in models]
+ else:
+ params = [client.Entity(tag.model(model)) for model in models]
+
+ await model_facade.DestroyModels(params)
destroy_model = destroy_models
async def add_user(self, username, password=None, display_name=None):
"""
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)]
- await user_facade.AddUser(users)
- return await self.get_user(username)
+ results = await user_facade.AddUser(users)
+ secret_key = results.results[0].secret_key
+ return await self.get_user(username, secret_key=secret_key)
async def remove_user(self, username):
"""Remove a user from this controller.
: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])
+ async def reset_user_password(self, username):
+ """Reset user password.
+
+ :param str username: Username
+ :returns: A :class:`~juju.user.User` instance
+ """
+ user_facade = client.UserManagerFacade.from_connection(
+ self.connection())
+ entity = client.Entity(tag.user(username))
+ results = await user_facade.ResetPassword([entity])
+ secret_key = results.results[0].secret_key
+ return await self.get_user(username, secret_key=secret_key)
+
async def destroy(self, destroy_all_models=False):
"""Destroy this controller.
: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])
"""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])
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()
def get_payloads(self, *patterns):
"""Return list of known payloads.
- :param str \*patterns: Patterns to match against
+ :param str *patterns: Patterns to match against
Each pattern will be checked against the following info in Juju::
await model._connect_direct(**kwargs)
return model
- async def get_user(self, username):
+ async def get_user(self, username, secret_key=None):
"""Get a user by name.
:param str username: Username
+ :param str secret_key: Issued by juju when add or reset user
+ password
:returns: A :class:`~juju.user.User` instance
"""
client_facade = client.UserManagerFacade.from_connection(
return None
raise
if response.results and response.results[0].result:
- return User(self, response.results[0].result)
+ return User(self, response.results[0].result, secret_key=secret_key)
return None
async def get_users(self, include_disabled=False):
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).
+ 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.
model_facade = client.ModelManagerFacade.from_connection(
self.connection())
user = tag.user(username)
- model = tag.model(self.info.uuid)
+ model = tag.model(model_uuid)
changes = client.ModifyModelAccess(acl, 'revoke', model, user)
return await model_facade.ModifyModelAccess([changes])