1 # Copyright 2019 Canonical Ltd.
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
7 # http://www.apache.org/licenses/LICENSE-2.0
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
17 from pathlib
import Path
19 from . import errors
, tag
, utils
20 from .client
import client
, connector
21 from .user
import User
23 log
= logging
.getLogger(__name__
)
34 """Instantiate a new Controller.
36 One of the connect_* methods will need to be called before this
37 object can be used for anything interesting.
39 If jujudata is None, jujudata.FileJujuData will be used.
41 :param loop: an asyncio event loop
42 :param max_frame_size: See
43 `juju.client.connection.Connection.MAX_FRAME_SIZE`
44 :param bakery_client httpbakery.Client: The bakery client to use
45 for macaroon authorization.
46 :param jujudata JujuData: The source for current controller
49 self
._connector
= connector
.Connector(
51 max_frame_size
=max_frame_size
,
52 bakery_client
=bakery_client
,
56 async def __aenter__(self
):
60 async def __aexit__(self
, exc_type
, exc
, tb
):
61 await self
.disconnect()
65 return self
._connector
.loop
67 async def connect(self
, *args
, **kwargs
):
68 """Connect to a Juju controller.
70 This supports two calling conventions:
72 The controller and (optionally) authentication information can be
73 taken from the data files created by the Juju CLI. This convention
74 will be used if a ``controller_name`` is specified, or if the
77 Otherwise, both the ``endpoint`` and authentication information
78 (``username`` and ``password``, or ``bakery_client`` and/or
79 ``macaroons``) are required.
81 If a single positional argument is given, it will be assumed to be
82 the ``controller_name``. Otherwise, the first positional argument,
83 if any, must be the ``endpoint``.
85 Available parameters are:
87 :param str controller_name: Name of controller registered with the
89 :param str endpoint: The hostname:port of the controller to connect to.
90 :param str username: The username for controller-local users (or None
91 to use macaroon-based login.)
92 :param str password: The password for controller-local users.
93 :param str cacert: The CA certificate of the controller
95 :param httpbakery.Client bakery_client: The macaroon bakery client to
96 to use when performing macaroon-based login. Macaroon tokens
97 acquired when logging will be saved to bakery_client.cookies.
98 If this is None, a default bakery_client will be used.
99 :param list macaroons: List of macaroons to load into the
101 :param asyncio.BaseEventLoop loop: The event loop to use for async
103 :param int max_frame_size: The maximum websocket frame size to allow.
105 await self
.disconnect()
106 if 'endpoint' not in kwargs
and len(args
) < 2:
107 if args
and 'model_name' in kwargs
:
108 raise TypeError('connect() got multiple values for '
111 controller_name
= args
[0]
113 controller_name
= kwargs
.pop('controller_name', None)
114 await self
._connector
.connect_controller(controller_name
, **kwargs
)
116 if 'controller_name' in kwargs
:
117 raise TypeError('connect() got values for both '
118 'controller_name and endpoint')
119 if args
and 'endpoint' in kwargs
:
120 raise TypeError('connect() got multiple values for endpoint')
121 has_userpass
= (len(args
) >= 3 or
122 {'username', 'password'}.issubset(kwargs
))
123 has_macaroons
= (len(args
) >= 5 or not
124 {'bakery_client', 'macaroons'}.isdisjoint(kwargs
))
125 if not (has_userpass
or has_macaroons
):
126 raise TypeError('connect() missing auth params')
137 for i
, arg
in enumerate(args
):
138 kwargs
[arg_names
[i
]] = arg
139 if 'endpoint' not in kwargs
:
140 raise ValueError('endpoint is required '
141 'if controller_name not given')
142 if not ({'username', 'password'}.issubset(kwargs
) or
143 {'bakery_client', 'macaroons'}.intersection(kwargs
)):
144 raise ValueError('Authentication parameters are required '
145 'if controller_name not given')
146 await self
._connector
.connect(**kwargs
)
148 async def connect_current(self
):
150 .. deprecated:: 0.7.3
151 Use :meth:`.connect()` instead.
153 return await self
.connect()
155 async def connect_controller(self
, controller_name
):
157 .. deprecated:: 0.7.3
158 Use :meth:`.connect(controller_name)` instead.
160 return await self
.connect(controller_name
)
162 async def _connect_direct(self
, **kwargs
):
163 await self
.disconnect()
164 await self
._connector
.connect(**kwargs
)
166 def is_connected(self
):
167 """Reports whether the Controller is currently connected."""
168 return self
._connector
.is_connected()
170 def connection(self
):
171 """Return the current Connection object. It raises an exception
172 if the Controller is disconnected"""
173 return self
._connector
.connection()
176 def controller_name(self
):
177 return self
._connector
.controller_name
179 async def disconnect(self
):
180 """Shut down the watcher task and close websockets.
183 await self
._connector
.disconnect()
185 async def add_credential(self
, name
=None, credential
=None, cloud
=None,
186 owner
=None, force
=False):
187 """Add or update a credential to the controller.
189 :param str name: Name of new credential. If None, the default
190 local credential is used. Name must be provided if a credential
192 :param CloudCredential credential: Credential to add. If not given,
193 it will attempt to read from local data, if available.
194 :param str cloud: Name of cloud to associate the credential with.
195 Defaults to the same cloud as the controller.
196 :param str owner: Username that will own the credential. Defaults to
198 :param bool force: Force indicates whether the update should be forced.
199 It's only supported for facade v3 or later.
201 :returns: Name of credential that was uploaded.
204 cloud
= await self
.get_cloud()
207 owner
= self
.connection().info
['user-info']['identity']
209 if credential
and not name
:
210 raise errors
.JujuError('Name must be provided for credential')
213 name
, credential
= self
._connector
.jujudata
.load_credential(cloud
,
215 if credential
is None:
216 raise errors
.JujuError(
217 'Unable to find credential: {}'.format(name
))
219 if credential
.auth_type
== 'jsonfile' and 'file' in credential
.attrs
:
220 # file creds have to be loaded before being sent to the controller
222 # it might already be JSON
223 json
.loads(credential
.attrs
['file'])
224 except json
.JSONDecodeError
:
225 # not valid JSON, so maybe it's a file
226 cred_path
= Path(credential
.attrs
['file'])
227 if cred_path
.exists():
229 cred_json
= credential
.to_json()
230 credential
= client
.CloudCredential
.from_json(cred_json
)
232 credential
.attrs
['file'] = cred_path
.read_text()
234 log
.debug('Uploading credential %s', name
)
235 cloud_facade
= client
.CloudFacade
.from_connection(self
.connection())
236 tagged_credentials
= [
237 client
.UpdateCloudCredential(
238 tag
=tag
.credential(cloud
, tag
.untag('user-', owner
), name
),
239 credential
=credential
,
241 if cloud_facade
.version
>= 3:
242 # UpdateCredentials was renamed to UpdateCredentialsCheckModels
243 # in facade version 3.
244 await cloud_facade
.UpdateCredentialsCheckModels(
245 credentials
=tagged_credentials
, force
=force
,
248 await cloud_facade
.UpdateCredentials(tagged_credentials
)
252 self
, model_name
, cloud_name
=None, credential_name
=None,
253 owner
=None, config
=None, region
=None):
254 """Add a model to this controller.
256 :param str model_name: Name to give the new model.
257 :param str cloud_name: Name of the cloud in which to create the
258 model, e.g. 'aws'. Defaults to same cloud as controller.
259 :param str credential_name: Name of the credential to use when
260 creating the model. If not given, it will attempt to find a
262 :param str owner: Username that will own the model. Defaults to
264 :param dict config: Model configuration.
265 :param str region: Region in which to create the model.
266 :return Model: A connection to the newly created model.
268 model_facade
= client
.ModelManagerFacade
.from_connection(
271 owner
= owner
or self
.connection().info
['user-info']['identity']
272 cloud_name
= cloud_name
or await self
.get_cloud()
275 # attempt to add/update the credential from local data if available
276 credential_name
= await self
.add_credential(
277 name
=credential_name
,
280 except errors
.JujuError
:
281 # if it's not available locally, assume it's on the controller
285 credential
= tag
.credential(
287 tag
.untag('user-', owner
),
293 log
.debug('Creating model %s', model_name
)
295 if not config
or 'authorized-keys' not in config
:
296 config
= config
or {}
297 config
['authorized-keys'] = await utils
.read_ssh_key(
298 loop
=self
._connector
.loop
)
300 model_info
= await model_facade
.CreateModel(
301 tag
.cloud(cloud_name
),
309 # This is a temporary workaround for a race condition:
310 # https://bugs.launchpad.net/juju/+bug/1838774
311 # This will be fixed when Juju 2.6.7 is released.
315 from juju
.model
import Model
316 model
= Model(jujudata
=self
._connector
.jujudata
)
317 kwargs
= self
.connection().connect_params()
318 kwargs
['uuid'] = model_info
.uuid
319 await model
._connect
_direct
(**kwargs
)
323 async def destroy_models(self
, *models
, destroy_storage
=False):
324 """Destroy one or more models.
326 :param str *models: Names or UUIDs of models to destroy
327 :param bool destroy_storage: Whether or not to destroy storage when
328 destroying the models. Defaults to false.
331 uuids
= await self
.model_uuids()
332 models
= [uuids
[model
] if model
in uuids
else model
335 model_facade
= client
.ModelManagerFacade
.from_connection(
339 'Destroying model%s %s',
340 '' if len(models
) == 1 else 's',
344 if model_facade
.version
>= 5:
346 client
.DestroyModelParams(model_tag
=tag
.model(model
),
347 destroy_storage
=destroy_storage
)
350 params
= [client
.Entity(tag
.model(model
)) for model
in models
]
352 await model_facade
.DestroyModels(params
)
353 destroy_model
= destroy_models
355 async def add_user(self
, username
, password
=None, display_name
=None):
356 """Add a user to this controller.
358 :param str username: Username
359 :param str password: Password
360 :param str display_name: Display name
361 :returns: A :class:`~juju.user.User` instance
364 display_name
= username
365 user_facade
= client
.UserManagerFacade
.from_connection(
367 users
= [client
.AddUser(display_name
=display_name
,
370 results
= await user_facade
.AddUser(users
)
371 secret_key
= results
.results
[0].secret_key
372 return await self
.get_user(username
, secret_key
=secret_key
)
374 async def remove_user(self
, username
):
375 """Remove a user from this controller.
377 client_facade
= client
.UserManagerFacade
.from_connection(
379 user
= tag
.user(username
)
380 await client_facade
.RemoveUser([client
.Entity(user
)])
382 async def change_user_password(self
, username
, password
):
383 """Change the password for a user in this controller.
385 :param str username: Username
386 :param str password: New password
389 user_facade
= client
.UserManagerFacade
.from_connection(
391 entity
= client
.EntityPassword(password
, tag
.user(username
))
392 return await user_facade
.SetPassword([entity
])
394 async def reset_user_password(self
, username
):
395 """Reset user password.
397 :param str username: Username
398 :returns: A :class:`~juju.user.User` instance
400 user_facade
= client
.UserManagerFacade
.from_connection(
402 entity
= client
.Entity(tag
.user(username
))
403 results
= await user_facade
.ResetPassword([entity
])
404 secret_key
= results
.results
[0].secret_key
405 return await self
.get_user(username
, secret_key
=secret_key
)
407 async def destroy(self
, destroy_all_models
=False):
408 """Destroy this controller.
410 :param bool destroy_all_models: Destroy all hosted models in the
414 controller_facade
= client
.ControllerFacade
.from_connection(
416 return await controller_facade
.DestroyController(destroy_all_models
)
418 async def disable_user(self
, username
):
421 :param str username: Username
424 user_facade
= client
.UserManagerFacade
.from_connection(
426 entity
= client
.Entity(tag
.user(username
))
427 return await user_facade
.DisableUser([entity
])
429 async def enable_user(self
, username
):
430 """Re-enable a previously disabled user.
433 user_facade
= client
.UserManagerFacade
.from_connection(
435 entity
= client
.Entity(tag
.user(username
))
436 return await user_facade
.EnableUser([entity
])
439 """Forcibly terminate all machines and other associated resources for
443 raise NotImplementedError()
445 async def get_cloud(self
):
447 Get the name of the cloud that this controller lives on.
449 cloud_facade
= client
.CloudFacade
.from_connection(self
.connection())
451 result
= await cloud_facade
.Clouds()
452 cloud
= list(result
.clouds
.keys())[0] # only lives on one cloud
453 return tag
.untag('cloud-', cloud
)
455 async def get_models(self
, all_
=False, username
=None):
457 .. deprecated:: 0.7.0
458 Use :meth:`.list_models` instead.
460 controller_facade
= client
.ControllerFacade
.from_connection(
462 for attempt
in (1, 2, 3):
464 return await controller_facade
.AllModels()
465 except errors
.JujuAPIError
as e
:
466 # retry concurrency error until resolved in Juju
467 # see: https://bugs.launchpad.net/juju/+bug/1721786
468 if 'has been removed' not in e
.message
or attempt
== 3:
471 async def model_uuids(self
):
472 """Return a mapping of model names to UUIDs.
474 controller_facade
= client
.ControllerFacade
.from_connection(
476 for attempt
in (1, 2, 3):
478 response
= await controller_facade
.AllModels()
479 return {um
.model
.name
: um
.model
.uuid
480 for um
in response
.user_models
}
481 except errors
.JujuAPIError
as e
:
482 # retry concurrency error until resolved in Juju
483 # see: https://bugs.launchpad.net/juju/+bug/1721786
484 if 'has been removed' not in e
.message
or attempt
== 3:
486 await asyncio
.sleep(attempt
, loop
=self
._connector
.loop
)
488 async def list_models(self
):
489 """Return list of names of the available models on this controller.
491 Equivalent to ``sorted((await self.model_uuids()).keys())``
493 uuids
= await self
.model_uuids()
494 return sorted(uuids
.keys())
496 def get_payloads(self
, *patterns
):
497 """Return list of known payloads.
499 :param str *patterns: Patterns to match against
501 Each pattern will be checked against the following info in Juju::
512 raise NotImplementedError()
515 """Log in to this controller.
518 raise NotImplementedError()
520 def logout(self
, force
=False):
521 """Log out of this controller.
523 :param bool force: Don't fail even if user not previously logged in
527 raise NotImplementedError()
529 async def get_model(self
, model
):
530 """Get a model by name or UUID.
532 :param str model: Model name or UUID
533 :returns Model: Connected Model instance.
535 uuids
= await self
.model_uuids()
541 from juju
.model
import Model
543 kwargs
= self
.connection().connect_params()
544 kwargs
['uuid'] = uuid
545 await model
._connect
_direct
(**kwargs
)
548 async def get_user(self
, username
, secret_key
=None):
549 """Get a user by name.
551 :param str username: Username
552 :param str secret_key: Issued by juju when add or reset user
554 :returns: A :class:`~juju.user.User` instance
556 client_facade
= client
.UserManagerFacade
.from_connection(
558 user
= tag
.user(username
)
559 args
= [client
.Entity(user
)]
561 response
= await client_facade
.UserInfo(args
, True)
562 except errors
.JujuError
as e
:
563 if 'permission denied' in e
.errors
:
564 # apparently, trying to get info for a nonexistent user returns
565 # a "permission denied" error rather than an empty result set
568 if response
.results
and response
.results
[0].result
:
569 return User(self
, response
.results
[0].result
, secret_key
=secret_key
)
572 async def get_users(self
, include_disabled
=False):
573 """Return list of users that can connect to this controller.
575 :param bool include_disabled: Include disabled users
576 :returns: A list of :class:`~juju.user.User` instances
578 client_facade
= client
.UserManagerFacade
.from_connection(
580 response
= await client_facade
.UserInfo(None, include_disabled
)
581 return [User(self
, r
.result
) for r
in response
.results
]
583 async def grant(self
, username
, acl
='login'):
584 """Grant access level of the given user on the controller.
585 Note that if the user already has higher permissions than the
586 provided ACL, this will do nothing (see revoke for a way to
588 :param str username: Username
589 :param str acl: Access control ('login', 'add-model' or 'superuser')
590 :returns: True if new access was granted, False if user already had
591 requested access or greater. Raises JujuError if failed.
593 controller_facade
= client
.ControllerFacade
.from_connection(
595 user
= tag
.user(username
)
596 changes
= client
.ModifyControllerAccess(acl
, 'grant', user
)
598 await controller_facade
.ModifyControllerAccess([changes
])
600 except errors
.JujuError
as e
:
601 if 'user already has' in str(e
):
606 async def revoke(self
, username
, acl
='login'):
607 """Removes some or all access of a user to from a controller
608 If 'login' access is revoked, the user will no longer have any
609 permissions on the controller. Revoking a higher privilege from
610 a user without that privilege will have no effect.
612 :param str username: username
613 :param str acl: Access to remove ('login', 'add-model' or 'superuser')
615 controller_facade
= client
.ControllerFacade
.from_connection(
617 user
= tag
.user(username
)
618 changes
= client
.ModifyControllerAccess('login', 'revoke', user
)
619 return await controller_facade
.ModifyControllerAccess([changes
])
621 async def grant_model(self
, username
, model_uuid
, acl
='read'):
622 """Grant a user access to a model. Note that if the user
623 already has higher permissions than the provided ACL,
624 this will do nothing (see revoke_model for a way to remove
627 :param str username: Username
628 :param str model_uuid: The UUID of the model to change.
629 :param str acl: Access control ('read, 'write' or 'admin')
631 model_facade
= client
.ModelManagerFacade
.from_connection(
633 user
= tag
.user(username
)
634 model
= tag
.model(model_uuid
)
635 changes
= client
.ModifyModelAccess(acl
, 'grant', model
, user
)
636 return await model_facade
.ModifyModelAccess([changes
])
638 async def revoke_model(self
, username
, model_uuid
, acl
='read'):
639 """Revoke some or all of a user's access to a model.
640 If 'read' access is revoked, the user will no longer have any
641 permissions on the model. Revoking a higher privilege from
642 a user without that privilege will have no effect.
644 :param str username: Username to revoke
645 :param str model_uuid: The UUID of the model to change.
646 :param str acl: Access control ('read, 'write' or 'admin')
648 model_facade
= client
.ModelManagerFacade
.from_connection(
650 user
= tag
.user(username
)
651 model
= tag
.model(model_uuid
)
652 changes
= client
.ModifyModelAccess(acl
, 'revoke', model
, user
)
653 return await model_facade
.ModifyModelAccess([changes
])