4 from pathlib
import Path
6 from . import errors
, tag
, utils
7 from .client
import client
, connector
10 log
= logging
.getLogger(__name__
)
21 """Instantiate a new Controller.
23 One of the connect_* methods will need to be called before this
24 object can be used for anything interesting.
26 If jujudata is None, jujudata.FileJujuData will be used.
28 :param loop: an asyncio event loop
29 :param max_frame_size: See
30 `juju.client.connection.Connection.MAX_FRAME_SIZE`
31 :param bakery_client httpbakery.Client: The bakery client to use
32 for macaroon authorization.
33 :param jujudata JujuData: The source for current controller
36 self
._connector
= connector
.Connector(
38 max_frame_size
=max_frame_size
,
39 bakery_client
=bakery_client
,
43 async def __aenter__(self
):
47 async def __aexit__(self
, exc_type
, exc
, tb
):
48 await self
.disconnect()
52 return self
._connector
.loop
54 async def connect(self
, *args
, **kwargs
):
55 """Connect to a Juju controller.
57 This supports two calling conventions:
59 The controller and (optionally) authentication information can be
60 taken from the data files created by the Juju CLI. This convention
61 will be used if a ``controller_name`` is specified, or if the
64 Otherwise, both the ``endpoint`` and authentication information
65 (``username`` and ``password``, or ``bakery_client`` and/or
66 ``macaroons``) are required.
68 If a single positional argument is given, it will be assumed to be
69 the ``controller_name``. Otherwise, the first positional argument,
70 if any, must be the ``endpoint``.
72 Available parameters are:
74 :param str controller_name: Name of controller registered with the
76 :param str endpoint: The hostname:port of the controller to connect to.
77 :param str username: The username for controller-local users (or None
78 to use macaroon-based login.)
79 :param str password: The password for controller-local users.
80 :param str cacert: The CA certificate of the controller
82 :param httpbakery.Client bakery_client: The macaroon bakery client to
83 to use when performing macaroon-based login. Macaroon tokens
84 acquired when logging will be saved to bakery_client.cookies.
85 If this is None, a default bakery_client will be used.
86 :param list macaroons: List of macaroons to load into the
88 :param asyncio.BaseEventLoop loop: The event loop to use for async
90 :param int max_frame_size: The maximum websocket frame size to allow.
92 await self
.disconnect()
93 if 'endpoint' not in kwargs
and len(args
) < 2:
94 if args
and 'model_name' in kwargs
:
95 raise TypeError('connect() got multiple values for '
98 controller_name
= args
[0]
100 controller_name
= kwargs
.pop('controller_name', None)
101 await self
._connector
.connect_controller(controller_name
, **kwargs
)
103 if 'controller_name' in kwargs
:
104 raise TypeError('connect() got values for both '
105 'controller_name and endpoint')
106 if args
and 'endpoint' in kwargs
:
107 raise TypeError('connect() got multiple values for endpoint')
108 has_userpass
= (len(args
) >= 3 or
109 {'username', 'password'}.issubset(kwargs
))
110 has_macaroons
= (len(args
) >= 5 or not
111 {'bakery_client', 'macaroons'}.isdisjoint(kwargs
))
112 if not (has_userpass
or has_macaroons
):
113 raise TypeError('connect() missing auth params')
124 for i
, arg
in enumerate(args
):
125 kwargs
[arg_names
[i
]] = arg
126 if 'endpoint' not in kwargs
:
127 raise ValueError('endpoint is required '
128 'if controller_name not given')
129 if not ({'username', 'password'}.issubset(kwargs
) or
130 {'bakery_client', 'macaroons'}.intersection(kwargs
)):
131 raise ValueError('Authentication parameters are required '
132 'if controller_name not given')
133 await self
._connector
.connect(**kwargs
)
135 async def connect_current(self
):
137 .. deprecated:: 0.7.3
138 Use :meth:`.connect()` instead.
140 return await self
.connect()
142 async def connect_controller(self
, controller_name
):
144 .. deprecated:: 0.7.3
145 Use :meth:`.connect(controller_name)` instead.
147 return await self
.connect(controller_name
)
149 async def _connect_direct(self
, **kwargs
):
150 await self
.disconnect()
151 await self
._connector
.connect(**kwargs
)
153 def is_connected(self
):
154 """Reports whether the Controller is currently connected."""
155 return self
._connector
.is_connected()
157 def connection(self
):
158 """Return the current Connection object. It raises an exception
159 if the Controller is disconnected"""
160 return self
._connector
.connection()
163 def controller_name(self
):
164 return self
._connector
.controller_name
166 async def disconnect(self
):
167 """Shut down the watcher task and close websockets.
170 await self
._connector
.disconnect()
172 async def add_credential(self
, name
=None, credential
=None, cloud
=None,
173 owner
=None, force
=False):
174 """Add or update a credential to the controller.
176 :param str name: Name of new credential. If None, the default
177 local credential is used. Name must be provided if a credential
179 :param CloudCredential credential: Credential to add. If not given,
180 it will attempt to read from local data, if available.
181 :param str cloud: Name of cloud to associate the credential with.
182 Defaults to the same cloud as the controller.
183 :param str owner: Username that will own the credential. Defaults to
185 :param bool force: Force indicates whether the update should be forced.
186 It's only supported for facade v3 or later.
188 :returns: Name of credential that was uploaded.
191 cloud
= await self
.get_cloud()
194 owner
= self
.connection().info
['user-info']['identity']
196 if credential
and not name
:
197 raise errors
.JujuError('Name must be provided for credential')
200 name
, credential
= self
._connector
.jujudata
.load_credential(cloud
,
202 if credential
is None:
203 raise errors
.JujuError(
204 'Unable to find credential: {}'.format(name
))
206 if credential
.auth_type
== 'jsonfile' and 'file' in credential
.attrs
:
207 # file creds have to be loaded before being sent to the controller
209 # it might already be JSON
210 json
.loads(credential
.attrs
['file'])
211 except json
.JSONDecodeError
:
212 # not valid JSON, so maybe it's a file
213 cred_path
= Path(credential
.attrs
['file'])
214 if cred_path
.exists():
216 cred_json
= credential
.to_json()
217 credential
= client
.CloudCredential
.from_json(cred_json
)
219 credential
.attrs
['file'] = cred_path
.read_text()
221 log
.debug('Uploading credential %s', name
)
222 cloud_facade
= client
.CloudFacade
.from_connection(self
.connection())
223 tagged_credentials
= [
224 client
.UpdateCloudCredential(
225 tag
=tag
.credential(cloud
, tag
.untag('user-', owner
), name
),
226 credential
=credential
,
228 if cloud_facade
.version
>= 3:
229 # UpdateCredentials was renamed to UpdateCredentialsCheckModels
230 # in facade version 3.
231 await cloud_facade
.UpdateCredentialsCheckModels(
232 credentials
=tagged_credentials
, force
=force
,
235 await cloud_facade
.UpdateCredentials(tagged_credentials
)
239 self
, model_name
, cloud_name
=None, credential_name
=None,
240 owner
=None, config
=None, region
=None):
241 """Add a model to this controller.
243 :param str model_name: Name to give the new model.
244 :param str cloud_name: Name of the cloud in which to create the
245 model, e.g. 'aws'. Defaults to same cloud as controller.
246 :param str credential_name: Name of the credential to use when
247 creating the model. If not given, it will attempt to find a
249 :param str owner: Username that will own the model. Defaults to
251 :param dict config: Model configuration.
252 :param str region: Region in which to create the model.
253 :return Model: A connection to the newly created model.
255 model_facade
= client
.ModelManagerFacade
.from_connection(
258 owner
= owner
or self
.connection().info
['user-info']['identity']
259 cloud_name
= cloud_name
or await self
.get_cloud()
262 # attempt to add/update the credential from local data if available
263 credential_name
= await self
.add_credential(
264 name
=credential_name
,
267 except errors
.JujuError
:
268 # if it's not available locally, assume it's on the controller
272 credential
= tag
.credential(
274 tag
.untag('user-', owner
),
280 log
.debug('Creating model %s', model_name
)
282 if not config
or 'authorized-keys' not in config
:
283 config
= config
or {}
284 config
['authorized-keys'] = await utils
.read_ssh_key(
285 loop
=self
._connector
.loop
)
287 model_info
= await model_facade
.CreateModel(
288 tag
.cloud(cloud_name
),
295 from juju
.model
import Model
296 model
= Model(jujudata
=self
._connector
.jujudata
)
297 kwargs
= self
.connection().connect_params()
298 kwargs
['uuid'] = model_info
.uuid
299 await model
._connect
_direct
(**kwargs
)
303 async def destroy_models(self
, *models
, destroy_storage
=False):
304 """Destroy one or more models.
306 :param str *models: Names or UUIDs of models to destroy
307 :param bool destroy_storage: Whether or not to destroy storage when
308 destroying the models. Defaults to false.
311 uuids
= await self
.model_uuids()
312 models
= [uuids
[model
] if model
in uuids
else model
315 model_facade
= client
.ModelManagerFacade
.from_connection(
319 'Destroying model%s %s',
320 '' if len(models
) == 1 else 's',
324 if model_facade
.version
>= 5:
326 client
.DestroyModelParams(model_tag
=tag
.model(model
),
327 destroy_storage
=destroy_storage
)
330 params
= [client
.Entity(tag
.model(model
)) for model
in models
]
332 await model_facade
.DestroyModels(params
)
333 destroy_model
= destroy_models
335 async def add_user(self
, username
, password
=None, display_name
=None):
336 """Add a user to this controller.
338 :param str username: Username
339 :param str password: Password
340 :param str display_name: Display name
341 :returns: A :class:`~juju.user.User` instance
344 display_name
= username
345 user_facade
= client
.UserManagerFacade
.from_connection(
347 users
= [client
.AddUser(display_name
=display_name
,
350 results
= await user_facade
.AddUser(users
)
351 secret_key
= results
.results
[0].secret_key
352 return await self
.get_user(username
, secret_key
=secret_key
)
354 async def remove_user(self
, username
):
355 """Remove a user from this controller.
357 client_facade
= client
.UserManagerFacade
.from_connection(
359 user
= tag
.user(username
)
360 await client_facade
.RemoveUser([client
.Entity(user
)])
362 async def change_user_password(self
, username
, password
):
363 """Change the password for a user in this controller.
365 :param str username: Username
366 :param str password: New password
369 user_facade
= client
.UserManagerFacade
.from_connection(
371 entity
= client
.EntityPassword(password
, tag
.user(username
))
372 return await user_facade
.SetPassword([entity
])
374 async def reset_user_password(self
, username
):
375 """Reset user password.
377 :param str username: Username
378 :returns: A :class:`~juju.user.User` instance
380 user_facade
= client
.UserManagerFacade
.from_connection(
382 entity
= client
.Entity(tag
.user(username
))
383 results
= await user_facade
.ResetPassword([entity
])
384 secret_key
= results
.results
[0].secret_key
385 return await self
.get_user(username
, secret_key
=secret_key
)
387 async def destroy(self
, destroy_all_models
=False):
388 """Destroy this controller.
390 :param bool destroy_all_models: Destroy all hosted models in the
394 controller_facade
= client
.ControllerFacade
.from_connection(
396 return await controller_facade
.DestroyController(destroy_all_models
)
398 async def disable_user(self
, username
):
401 :param str username: Username
404 user_facade
= client
.UserManagerFacade
.from_connection(
406 entity
= client
.Entity(tag
.user(username
))
407 return await user_facade
.DisableUser([entity
])
409 async def enable_user(self
, username
):
410 """Re-enable a previously disabled user.
413 user_facade
= client
.UserManagerFacade
.from_connection(
415 entity
= client
.Entity(tag
.user(username
))
416 return await user_facade
.EnableUser([entity
])
419 """Forcibly terminate all machines and other associated resources for
423 raise NotImplementedError()
425 async def get_cloud(self
):
427 Get the name of the cloud that this controller lives on.
429 cloud_facade
= client
.CloudFacade
.from_connection(self
.connection())
431 result
= await cloud_facade
.Clouds()
432 cloud
= list(result
.clouds
.keys())[0] # only lives on one cloud
433 return tag
.untag('cloud-', cloud
)
435 async def get_models(self
, all_
=False, username
=None):
437 .. deprecated:: 0.7.0
438 Use :meth:`.list_models` instead.
440 controller_facade
= client
.ControllerFacade
.from_connection(
442 for attempt
in (1, 2, 3):
444 return await controller_facade
.AllModels()
445 except errors
.JujuAPIError
as e
:
446 # retry concurrency error until resolved in Juju
447 # see: https://bugs.launchpad.net/juju/+bug/1721786
448 if 'has been removed' not in e
.message
or attempt
== 3:
451 async def model_uuids(self
):
452 """Return a mapping of model names to UUIDs.
454 controller_facade
= client
.ControllerFacade
.from_connection(
456 for attempt
in (1, 2, 3):
458 response
= await controller_facade
.AllModels()
459 return {um
.model
.name
: um
.model
.uuid
460 for um
in response
.user_models
}
461 except errors
.JujuAPIError
as e
:
462 # retry concurrency error until resolved in Juju
463 # see: https://bugs.launchpad.net/juju/+bug/1721786
464 if 'has been removed' not in e
.message
or attempt
== 3:
466 await asyncio
.sleep(attempt
, loop
=self
._connector
.loop
)
468 async def list_models(self
):
469 """Return list of names of the available models on this controller.
471 Equivalent to ``sorted((await self.model_uuids()).keys())``
473 uuids
= await self
.model_uuids()
474 return sorted(uuids
.keys())
476 def get_payloads(self
, *patterns
):
477 """Return list of known payloads.
479 :param str *patterns: Patterns to match against
481 Each pattern will be checked against the following info in Juju::
492 raise NotImplementedError()
495 """Log in to this controller.
498 raise NotImplementedError()
500 def logout(self
, force
=False):
501 """Log out of this controller.
503 :param bool force: Don't fail even if user not previously logged in
507 raise NotImplementedError()
509 async def get_model(self
, model
):
510 """Get a model by name or UUID.
512 :param str model: Model name or UUID
513 :returns Model: Connected Model instance.
515 uuids
= await self
.model_uuids()
521 from juju
.model
import Model
523 kwargs
= self
.connection().connect_params()
524 kwargs
['uuid'] = uuid
525 await model
._connect
_direct
(**kwargs
)
528 async def get_user(self
, username
, secret_key
=None):
529 """Get a user by name.
531 :param str username: Username
532 :param str secret_key: Issued by juju when add or reset user
534 :returns: A :class:`~juju.user.User` instance
536 client_facade
= client
.UserManagerFacade
.from_connection(
538 user
= tag
.user(username
)
539 args
= [client
.Entity(user
)]
541 response
= await client_facade
.UserInfo(args
, True)
542 except errors
.JujuError
as e
:
543 if 'permission denied' in e
.errors
:
544 # apparently, trying to get info for a nonexistent user returns
545 # a "permission denied" error rather than an empty result set
548 if response
.results
and response
.results
[0].result
:
549 return User(self
, response
.results
[0].result
, secret_key
=secret_key
)
552 async def get_users(self
, include_disabled
=False):
553 """Return list of users that can connect to this controller.
555 :param bool include_disabled: Include disabled users
556 :returns: A list of :class:`~juju.user.User` instances
558 client_facade
= client
.UserManagerFacade
.from_connection(
560 response
= await client_facade
.UserInfo(None, include_disabled
)
561 return [User(self
, r
.result
) for r
in response
.results
]
563 async def grant(self
, username
, acl
='login'):
564 """Grant access level of the given user on the controller.
565 Note that if the user already has higher permissions than the
566 provided ACL, this will do nothing (see revoke for a way to
568 :param str username: Username
569 :param str acl: Access control ('login', 'add-model' or 'superuser')
570 :returns: True if new access was granted, False if user already had
571 requested access or greater. Raises JujuError if failed.
573 controller_facade
= client
.ControllerFacade
.from_connection(
575 user
= tag
.user(username
)
576 changes
= client
.ModifyControllerAccess(acl
, 'grant', user
)
578 await controller_facade
.ModifyControllerAccess([changes
])
580 except errors
.JujuError
as e
:
581 if 'user already has' in str(e
):
586 async def revoke(self
, username
, acl
='login'):
587 """Removes some or all access of a user to from a controller
588 If 'login' access is revoked, the user will no longer have any
589 permissions on the controller. Revoking a higher privilege from
590 a user without that privilege will have no effect.
592 :param str username: username
593 :param str acl: Access to remove ('login', 'add-model' or 'superuser')
595 controller_facade
= client
.ControllerFacade
.from_connection(
597 user
= tag
.user(username
)
598 changes
= client
.ModifyControllerAccess('login', 'revoke', user
)
599 return await controller_facade
.ModifyControllerAccess([changes
])
601 async def grant_model(self
, username
, model_uuid
, acl
='read'):
602 """Grant a user access to a model. Note that if the user
603 already has higher permissions than the provided ACL,
604 this will do nothing (see revoke_model for a way to remove
607 :param str username: Username
608 :param str model_uuid: The UUID of the model to change.
609 :param str acl: Access control ('read, 'write' or 'admin')
611 model_facade
= client
.ModelManagerFacade
.from_connection(
613 user
= tag
.user(username
)
614 model
= tag
.model(model_uuid
)
615 changes
= client
.ModifyModelAccess(acl
, 'grant', model
, user
)
616 return await model_facade
.ModifyModelAccess([changes
])
618 async def revoke_model(self
, username
, model_uuid
, acl
='read'):
619 """Revoke some or all of a user's access to a model.
620 If 'read' access is revoked, the user will no longer have any
621 permissions on the model. Revoking a higher privilege from
622 a user without that privilege will have no effect.
624 :param str username: Username to revoke
625 :param str model_uuid: The UUID of the model to change.
626 :param str acl: Access control ('read, 'write' or 'admin')
628 model_facade
= client
.ModelManagerFacade
.from_connection(
630 user
= tag
.user(username
)
631 model
= tag
.model(model_uuid
)
632 changes
= client
.ModifyModelAccess(acl
, 'revoke', model
, user
)
633 return await model_facade
.ModifyModelAccess([changes
])