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 information.
35 self
._connector
= connector
.Connector(
37 max_frame_size
=max_frame_size
,
38 bakery_client
=bakery_client
,
42 async def __aenter__(self
):
46 async def __aexit__(self
, exc_type
, exc
, tb
):
47 await self
.disconnect()
51 return self
._connector
.loop
53 async def connect(self
, *args
, **kwargs
):
54 """Connect to a Juju controller.
56 This supports two calling conventions:
58 The controller and (optionally) authentication information can be
59 taken from the data files created by the Juju CLI. This convention
60 will be used if a ``controller_name`` is specified, or if the
63 Otherwise, both the ``endpoint`` and authentication information
64 (``username`` and ``password``, or ``bakery_client`` and/or
65 ``macaroons``) are required.
67 If a single positional argument is given, it will be assumed to be
68 the ``controller_name``. Otherwise, the first positional argument,
69 if any, must be the ``endpoint``.
71 Available parameters are:
73 :param str controller_name: Name of controller registered with the
75 :param str endpoint: The hostname:port of the controller to connect to.
76 :param str username: The username for controller-local users (or None
77 to use macaroon-based login.)
78 :param str password: The password for controller-local users.
79 :param str cacert: The CA certificate of the controller
81 :param httpbakery.Client bakery_client: The macaroon bakery client to
82 to use when performing macaroon-based login. Macaroon tokens
83 acquired when logging will be saved to bakery_client.cookies.
84 If this is None, a default bakery_client will be used.
85 :param list macaroons: List of macaroons to load into the
87 :param asyncio.BaseEventLoop loop: The event loop to use for async
89 :param int max_frame_size: The maximum websocket frame size to allow.
91 await self
.disconnect()
92 if 'endpoint' not in kwargs
and len(args
) < 2:
93 if args
and 'model_name' in kwargs
:
94 raise TypeError('connect() got multiple values for '
97 controller_name
= args
[0]
99 controller_name
= kwargs
.pop('controller_name', None)
100 await self
._connector
.connect_controller(controller_name
, **kwargs
)
102 if 'controller_name' in kwargs
:
103 raise TypeError('connect() got values for both '
104 'controller_name and endpoint')
105 if args
and 'endpoint' in kwargs
:
106 raise TypeError('connect() got multiple values for endpoint')
107 has_userpass
= (len(args
) >= 3 or
108 {'username', 'password'}.issubset(kwargs
))
109 has_macaroons
= (len(args
) >= 5 or not
110 {'bakery_client', 'macaroons'}.isdisjoint(kwargs
))
111 if not (has_userpass
or has_macaroons
):
112 raise TypeError('connect() missing auth params')
123 for i
, arg
in enumerate(args
):
124 kwargs
[arg_names
[i
]] = arg
125 if 'endpoint' not in kwargs
:
126 raise ValueError('endpoint is required '
127 'if controller_name not given')
128 if not ({'username', 'password'}.issubset(kwargs
) or
129 {'bakery_client', 'macaroons'}.intersection(kwargs
)):
130 raise ValueError('Authentication parameters are required '
131 'if controller_name not given')
132 await self
._connector
.connect(**kwargs
)
134 async def connect_current(self
):
136 .. deprecated:: 0.7.3
137 Use :meth:`.connect()` instead.
139 return await self
.connect()
141 async def connect_controller(self
, controller_name
):
143 .. deprecated:: 0.7.3
144 Use :meth:`.connect(controller_name)` instead.
146 return await self
.connect(controller_name
)
148 async def _connect_direct(self
, **kwargs
):
149 await self
.disconnect()
150 await self
._connector
.connect(**kwargs
)
152 def is_connected(self
):
153 """Reports whether the Controller is currently connected."""
154 return self
._connector
.is_connected()
156 def connection(self
):
157 """Return the current Connection object. It raises an exception
158 if the Controller is disconnected"""
159 return self
._connector
.connection()
162 def controller_name(self
):
163 return self
._connector
.controller_name
165 async def disconnect(self
):
166 """Shut down the watcher task and close websockets.
169 await self
._connector
.disconnect()
171 async def add_credential(self
, name
=None, credential
=None, cloud
=None,
173 """Add or update a credential to the controller.
175 :param str name: Name of new credential. If None, the default
176 local credential is used. Name must be provided if a credential
178 :param CloudCredential credential: Credential to add. If not given,
179 it will attempt to read from local data, if available.
180 :param str cloud: Name of cloud to associate the credential with.
181 Defaults to the same cloud as the controller.
182 :param str owner: Username that will own the credential. Defaults to
184 :returns: Name of credential that was uploaded.
187 cloud
= await self
.get_cloud()
190 owner
= self
.connection().info
['user-info']['identity']
192 if credential
and not name
:
193 raise errors
.JujuError('Name must be provided for credential')
196 name
, credential
= self
._connector
.jujudata
.load_credential(cloud
, name
)
197 if credential
is None:
198 raise errors
.JujuError(
199 'Unable to find credential: {}'.format(name
))
201 if credential
.auth_type
== 'jsonfile' and 'file' in credential
.attrs
:
202 # file creds have to be loaded before being sent to the controller
204 # it might already be JSON
205 json
.loads(credential
.attrs
['file'])
206 except json
.JSONDecodeError
:
207 # not valid JSON, so maybe it's a file
208 cred_path
= Path(credential
.attrs
['file'])
209 if cred_path
.exists():
211 cred_json
= credential
.to_json()
212 credential
= client
.CloudCredential
.from_json(cred_json
)
214 credential
.attrs
['file'] = cred_path
.read_text()
216 log
.debug('Uploading credential %s', name
)
217 cloud_facade
= client
.CloudFacade
.from_connection(self
.connection())
218 await cloud_facade
.UpdateCredentials([
219 client
.UpdateCloudCredential(
220 tag
=tag
.credential(cloud
, tag
.untag('user-', owner
), name
),
221 credential
=credential
,
227 self
, model_name
, cloud_name
=None, credential_name
=None,
228 owner
=None, config
=None, region
=None):
229 """Add a model to this controller.
231 :param str model_name: Name to give the new model.
232 :param str cloud_name: Name of the cloud in which to create the
233 model, e.g. 'aws'. Defaults to same cloud as controller.
234 :param str credential_name: Name of the credential to use when
235 creating the model. If not given, it will attempt to find a
237 :param str owner: Username that will own the model. Defaults to
239 :param dict config: Model configuration.
240 :param str region: Region in which to create the model.
241 :return Model: A connection to the newly created model.
243 model_facade
= client
.ModelManagerFacade
.from_connection(
246 owner
= owner
or self
.connection().info
['user-info']['identity']
247 cloud_name
= cloud_name
or await self
.get_cloud()
250 # attempt to add/update the credential from local data if available
251 credential_name
= await self
.add_credential(
252 name
=credential_name
,
255 except errors
.JujuError
:
256 # if it's not available locally, assume it's on the controller
260 credential
= tag
.credential(
262 tag
.untag('user-', owner
),
268 log
.debug('Creating model %s', model_name
)
270 if not config
or 'authorized-keys' not in config
:
271 config
= config
or {}
272 config
['authorized-keys'] = await utils
.read_ssh_key(
273 loop
=self
._connector
.loop
)
275 model_info
= await model_facade
.CreateModel(
276 tag
.cloud(cloud_name
),
283 from juju
.model
import Model
284 model
= Model(jujudata
=self
._connector
.jujudata
)
285 kwargs
= self
.connection().connect_params()
286 kwargs
['uuid'] = model_info
.uuid
287 await model
._connect
_direct
(**kwargs
)
291 async def destroy_models(self
, *models
):
292 """Destroy one or more models.
294 :param str \*models: Names or UUIDs of models to destroy
297 uuids
= await self
.model_uuids()
298 models
= [uuids
[model
] if model
in uuids
else model
301 model_facade
= client
.ModelManagerFacade
.from_connection(
305 'Destroying model%s %s',
306 '' if len(models
) == 1 else 's',
310 await model_facade
.DestroyModels([
311 client
.Entity(tag
.model(model
))
314 destroy_model
= destroy_models
316 async def add_user(self
, username
, password
=None, display_name
=None):
317 """Add a user to this controller.
319 :param str username: Username
320 :param str password: Password
321 :param str display_name: Display name
322 :returns: A :class:`~juju.user.User` instance
325 display_name
= username
326 user_facade
= client
.UserManagerFacade
.from_connection(self
.connection())
327 users
= [client
.AddUser(display_name
=display_name
,
330 await user_facade
.AddUser(users
)
331 return await self
.get_user(username
)
333 async def remove_user(self
, username
):
334 """Remove a user from this controller.
336 client_facade
= client
.UserManagerFacade
.from_connection(
338 user
= tag
.user(username
)
339 await client_facade
.RemoveUser([client
.Entity(user
)])
341 async def change_user_password(self
, username
, password
):
342 """Change the password for a user in this controller.
344 :param str username: Username
345 :param str password: New password
348 user_facade
= client
.UserManagerFacade
.from_connection(self
.connection())
349 entity
= client
.EntityPassword(password
, tag
.user(username
))
350 return await user_facade
.SetPassword([entity
])
352 async def destroy(self
, destroy_all_models
=False):
353 """Destroy this controller.
355 :param bool destroy_all_models: Destroy all hosted models in the
359 controller_facade
= client
.ControllerFacade
.from_connection(
361 return await controller_facade
.DestroyController(destroy_all_models
)
363 async def disable_user(self
, username
):
366 :param str username: Username
369 user_facade
= client
.UserManagerFacade
.from_connection(self
.connection())
370 entity
= client
.Entity(tag
.user(username
))
371 return await user_facade
.DisableUser([entity
])
373 async def enable_user(self
, username
):
374 """Re-enable a previously disabled user.
377 user_facade
= client
.UserManagerFacade
.from_connection(self
.connection())
378 entity
= client
.Entity(tag
.user(username
))
379 return await user_facade
.EnableUser([entity
])
382 """Forcibly terminate all machines and other associated resources for
386 raise NotImplementedError()
388 async def get_cloud(self
):
390 Get the name of the cloud that this controller lives on.
392 cloud_facade
= client
.CloudFacade
.from_connection(self
.connection())
394 result
= await cloud_facade
.Clouds()
395 cloud
= list(result
.clouds
.keys())[0] # only lives on one cloud
396 return tag
.untag('cloud-', cloud
)
398 async def get_models(self
, all_
=False, username
=None):
400 .. deprecated:: 0.7.0
401 Use :meth:`.list_models` instead.
403 controller_facade
= client
.ControllerFacade
.from_connection(
405 for attempt
in (1, 2, 3):
407 return await controller_facade
.AllModels()
408 except errors
.JujuAPIError
as e
:
409 # retry concurrency error until resolved in Juju
410 # see: https://bugs.launchpad.net/juju/+bug/1721786
411 if 'has been removed' not in e
.message
or attempt
== 3:
414 async def model_uuids(self
):
415 """Return a mapping of model names to UUIDs.
417 controller_facade
= client
.ControllerFacade
.from_connection(
419 for attempt
in (1, 2, 3):
421 response
= await controller_facade
.AllModels()
422 return {um
.model
.name
: um
.model
.uuid
423 for um
in response
.user_models
}
424 except errors
.JujuAPIError
as e
:
425 # retry concurrency error until resolved in Juju
426 # see: https://bugs.launchpad.net/juju/+bug/1721786
427 if 'has been removed' not in e
.message
or attempt
== 3:
429 await asyncio
.sleep(attempt
, loop
=self
._connector
.loop
)
431 async def list_models(self
):
432 """Return list of names of the available models on this controller.
434 Equivalent to ``sorted((await self.model_uuids()).keys())``
436 uuids
= await self
.model_uuids()
437 return sorted(uuids
.keys())
439 def get_payloads(self
, *patterns
):
440 """Return list of known payloads.
442 :param str \*patterns: Patterns to match against
444 Each pattern will be checked against the following info in Juju::
455 raise NotImplementedError()
458 """Log in to this controller.
461 raise NotImplementedError()
463 def logout(self
, force
=False):
464 """Log out of this controller.
466 :param bool force: Don't fail even if user not previously logged in
470 raise NotImplementedError()
472 async def get_model(self
, model
):
473 """Get a model by name or UUID.
475 :param str model: Model name or UUID
476 :returns Model: Connected Model instance.
478 uuids
= await self
.model_uuids()
484 from juju
.model
import Model
486 kwargs
= self
.connection().connect_params()
487 kwargs
['uuid'] = uuid
488 await model
._connect
_direct
(**kwargs
)
491 async def get_user(self
, username
):
492 """Get a user by name.
494 :param str username: Username
495 :returns: A :class:`~juju.user.User` instance
497 client_facade
= client
.UserManagerFacade
.from_connection(
499 user
= tag
.user(username
)
500 args
= [client
.Entity(user
)]
502 response
= await client_facade
.UserInfo(args
, True)
503 except errors
.JujuError
as e
:
504 if 'permission denied' in e
.errors
:
505 # apparently, trying to get info for a nonexistent user returns
506 # a "permission denied" error rather than an empty result set
509 if response
.results
and response
.results
[0].result
:
510 return User(self
, response
.results
[0].result
)
513 async def get_users(self
, include_disabled
=False):
514 """Return list of users that can connect to this controller.
516 :param bool include_disabled: Include disabled users
517 :returns: A list of :class:`~juju.user.User` instances
519 client_facade
= client
.UserManagerFacade
.from_connection(
521 response
= await client_facade
.UserInfo(None, include_disabled
)
522 return [User(self
, r
.result
) for r
in response
.results
]
524 async def grant(self
, username
, acl
='login'):
525 """Grant access level of the given user on the controller.
526 Note that if the user already has higher permissions than the
527 provided ACL, this will do nothing (see revoke for a way to
529 :param str username: Username
530 :param str acl: Access control ('login', 'add-model' or 'superuser')
531 :returns: True if new access was granted, False if user already had
532 requested access or greater. Raises JujuError if failed.
534 controller_facade
= client
.ControllerFacade
.from_connection(
536 user
= tag
.user(username
)
537 changes
= client
.ModifyControllerAccess(acl
, 'grant', user
)
539 await controller_facade
.ModifyControllerAccess([changes
])
541 except errors
.JujuError
as e
:
542 if 'user already has' in str(e
):
547 async def revoke(self
, username
, acl
='login'):
548 """Removes some or all access of a user to from a controller
549 If 'login' access is revoked, the user will no longer have any
550 permissions on the controller. Revoking a higher privilege from
551 a user without that privilege will have no effect.
553 :param str username: username
554 :param str acl: Access to remove ('login', 'add-model' or 'superuser')
556 controller_facade
= client
.ControllerFacade
.from_connection(
558 user
= tag
.user(username
)
559 changes
= client
.ModifyControllerAccess('login', 'revoke', user
)
560 return await controller_facade
.ModifyControllerAccess([changes
])
562 async def grant_model(self
, username
, model_uuid
, acl
='read'):
563 """Grant a user access to a model. Note that if the user
564 already has higher permissions than the provided ACL,
565 this will do nothing (see revoke_model for a way to remove permissions).
567 :param str username: Username
568 :param str model_uuid: The UUID of the model to change.
569 :param str acl: Access control ('read, 'write' or 'admin')
571 model_facade
= client
.ModelManagerFacade
.from_connection(
573 user
= tag
.user(username
)
574 model
= tag
.model(model_uuid
)
575 changes
= client
.ModifyModelAccess(acl
, 'grant', model
, user
)
576 return await model_facade
.ModifyModelAccess([changes
])
578 async def revoke_model(self
, username
, model_uuid
, acl
='read'):
579 """Revoke some or all of a user's access to a model.
580 If 'read' access is revoked, the user will no longer have any
581 permissions on the model. Revoking a higher privilege from
582 a user without that privilege will have no effect.
584 :param str username: Username to revoke
585 :param str model_uuid: The UUID of the model to change.
586 :param str acl: Access control ('read, 'write' or 'admin')
588 model_facade
= client
.ModelManagerFacade
.from_connection(
590 user
= tag
.user(username
)
591 model
= tag
.model(self
.info
.uuid
)
592 changes
= client
.ModifyModelAccess(acl
, 'revoke', model
, user
)
593 return await model_facade
.ModifyModelAccess([changes
])