Fix bug 760
[osm/N2VC.git] / modules / libjuju / juju / controller.py
1 import asyncio
2 import json
3 import logging
4 from pathlib import Path
5
6 from . import errors, tag, utils
7 from .client import client, connector
8 from .user import User
9
10 log = logging.getLogger(__name__)
11
12
13 class Controller:
14 def __init__(
15 self,
16 loop=None,
17 max_frame_size=None,
18 bakery_client=None,
19 jujudata=None,
20 ):
21 """Instantiate a new Controller.
22
23 One of the connect_* methods will need to be called before this
24 object can be used for anything interesting.
25
26 If jujudata is None, jujudata.FileJujuData will be used.
27
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
34 information.
35 """
36 self._connector = connector.Connector(
37 loop=loop,
38 max_frame_size=max_frame_size,
39 bakery_client=bakery_client,
40 jujudata=jujudata,
41 )
42
43 async def __aenter__(self):
44 await self.connect()
45 return self
46
47 async def __aexit__(self, exc_type, exc, tb):
48 await self.disconnect()
49
50 @property
51 def loop(self):
52 return self._connector.loop
53
54 async def connect(self, *args, **kwargs):
55 """Connect to a Juju controller.
56
57 This supports two calling conventions:
58
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
62 ``endpoint`` is not.
63
64 Otherwise, both the ``endpoint`` and authentication information
65 (``username`` and ``password``, or ``bakery_client`` and/or
66 ``macaroons``) are required.
67
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``.
71
72 Available parameters are:
73
74 :param str controller_name: Name of controller registered with the
75 Juju CLI.
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
81 (PEM formatted).
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
87 ``bakery_client``.
88 :param asyncio.BaseEventLoop loop: The event loop to use for async
89 operations.
90 :param int max_frame_size: The maximum websocket frame size to allow.
91 """
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 '
96 'controller_name')
97 elif args:
98 controller_name = args[0]
99 else:
100 controller_name = kwargs.pop('controller_name', None)
101 await self._connector.connect_controller(controller_name, **kwargs)
102 else:
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')
114 arg_names = [
115 'endpoint',
116 'username',
117 'password',
118 'cacert',
119 'bakery_client',
120 'macaroons',
121 'loop',
122 'max_frame_size',
123 ]
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)
134
135 async def connect_current(self):
136 """
137 .. deprecated:: 0.7.3
138 Use :meth:`.connect()` instead.
139 """
140 return await self.connect()
141
142 async def connect_controller(self, controller_name):
143 """
144 .. deprecated:: 0.7.3
145 Use :meth:`.connect(controller_name)` instead.
146 """
147 return await self.connect(controller_name)
148
149 async def _connect_direct(self, **kwargs):
150 await self.disconnect()
151 await self._connector.connect(**kwargs)
152
153 def is_connected(self):
154 """Reports whether the Controller is currently connected."""
155 return self._connector.is_connected()
156
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()
161
162 @property
163 def controller_name(self):
164 return self._connector.controller_name
165
166 async def disconnect(self):
167 """Shut down the watcher task and close websockets.
168
169 """
170 await self._connector.disconnect()
171
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.
175
176 :param str name: Name of new credential. If None, the default
177 local credential is used. Name must be provided if a credential
178 is given.
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
184 the current user.
185 :param bool force: Force indicates whether the update should be forced.
186 It's only supported for facade v3 or later.
187 Defaults to false.
188 :returns: Name of credential that was uploaded.
189 """
190 if not cloud:
191 cloud = await self.get_cloud()
192
193 if not owner:
194 owner = self.connection().info['user-info']['identity']
195
196 if credential and not name:
197 raise errors.JujuError('Name must be provided for credential')
198
199 if not credential:
200 name, credential = self._connector.jujudata.load_credential(cloud,
201 name)
202 if credential is None:
203 raise errors.JujuError(
204 'Unable to find credential: {}'.format(name))
205
206 if credential.auth_type == 'jsonfile' and 'file' in credential.attrs:
207 # file creds have to be loaded before being sent to the controller
208 try:
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():
215 # make a copy
216 cred_json = credential.to_json()
217 credential = client.CloudCredential.from_json(cred_json)
218 # inline the cred
219 credential.attrs['file'] = cred_path.read_text()
220
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,
227 )]
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,
233 )
234 else:
235 await cloud_facade.UpdateCredentials(tagged_credentials)
236 return name
237
238 async def add_model(
239 self, model_name, cloud_name=None, credential_name=None,
240 owner=None, config=None, region=None):
241 """Add a model to this controller.
242
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
248 default credential.
249 :param str owner: Username that will own the model. Defaults to
250 the current user.
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.
254 """
255 model_facade = client.ModelManagerFacade.from_connection(
256 self.connection())
257
258 owner = owner or self.connection().info['user-info']['identity']
259 cloud_name = cloud_name or await self.get_cloud()
260
261 try:
262 # attempt to add/update the credential from local data if available
263 credential_name = await self.add_credential(
264 name=credential_name,
265 cloud=cloud_name,
266 owner=owner)
267 except errors.JujuError:
268 # if it's not available locally, assume it's on the controller
269 pass
270
271 if credential_name:
272 credential = tag.credential(
273 cloud_name,
274 tag.untag('user-', owner),
275 credential_name
276 )
277 else:
278 credential = None
279
280 log.debug('Creating model %s', model_name)
281
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)
286
287 model_info = await model_facade.CreateModel(
288 tag.cloud(cloud_name),
289 config,
290 credential,
291 model_name,
292 owner,
293 region
294 )
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)
300
301 return model
302
303 async def destroy_models(self, *models, destroy_storage=False):
304 """Destroy one or more models.
305
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.
309
310 """
311 uuids = await self.model_uuids()
312 models = [uuids[model] if model in uuids else model
313 for model in models]
314
315 model_facade = client.ModelManagerFacade.from_connection(
316 self.connection())
317
318 log.debug(
319 'Destroying model%s %s',
320 '' if len(models) == 1 else 's',
321 ', '.join(models)
322 )
323
324 if model_facade.version >= 5:
325 params = [
326 client.DestroyModelParams(model_tag=tag.model(model),
327 destroy_storage=destroy_storage)
328 for model in models]
329 else:
330 params = [client.Entity(tag.model(model)) for model in models]
331
332 await model_facade.DestroyModels(params)
333 destroy_model = destroy_models
334
335 async def add_user(self, username, password=None, display_name=None):
336 """Add a user to this controller.
337
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
342 """
343 if not display_name:
344 display_name = username
345 user_facade = client.UserManagerFacade.from_connection(
346 self.connection())
347 users = [client.AddUser(display_name=display_name,
348 username=username,
349 password=password)]
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)
353
354 async def remove_user(self, username):
355 """Remove a user from this controller.
356 """
357 client_facade = client.UserManagerFacade.from_connection(
358 self.connection())
359 user = tag.user(username)
360 await client_facade.RemoveUser([client.Entity(user)])
361
362 async def change_user_password(self, username, password):
363 """Change the password for a user in this controller.
364
365 :param str username: Username
366 :param str password: New password
367
368 """
369 user_facade = client.UserManagerFacade.from_connection(
370 self.connection())
371 entity = client.EntityPassword(password, tag.user(username))
372 return await user_facade.SetPassword([entity])
373
374 async def reset_user_password(self, username):
375 """Reset user password.
376
377 :param str username: Username
378 :returns: A :class:`~juju.user.User` instance
379 """
380 user_facade = client.UserManagerFacade.from_connection(
381 self.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)
386
387 async def destroy(self, destroy_all_models=False):
388 """Destroy this controller.
389
390 :param bool destroy_all_models: Destroy all hosted models in the
391 controller.
392
393 """
394 controller_facade = client.ControllerFacade.from_connection(
395 self.connection())
396 return await controller_facade.DestroyController(destroy_all_models)
397
398 async def disable_user(self, username):
399 """Disable a user.
400
401 :param str username: Username
402
403 """
404 user_facade = client.UserManagerFacade.from_connection(
405 self.connection())
406 entity = client.Entity(tag.user(username))
407 return await user_facade.DisableUser([entity])
408
409 async def enable_user(self, username):
410 """Re-enable a previously disabled user.
411
412 """
413 user_facade = client.UserManagerFacade.from_connection(
414 self.connection())
415 entity = client.Entity(tag.user(username))
416 return await user_facade.EnableUser([entity])
417
418 def kill(self):
419 """Forcibly terminate all machines and other associated resources for
420 this controller.
421
422 """
423 raise NotImplementedError()
424
425 async def get_cloud(self):
426 """
427 Get the name of the cloud that this controller lives on.
428 """
429 cloud_facade = client.CloudFacade.from_connection(self.connection())
430
431 result = await cloud_facade.Clouds()
432 cloud = list(result.clouds.keys())[0] # only lives on one cloud
433 return tag.untag('cloud-', cloud)
434
435 async def get_models(self, all_=False, username=None):
436 """
437 .. deprecated:: 0.7.0
438 Use :meth:`.list_models` instead.
439 """
440 controller_facade = client.ControllerFacade.from_connection(
441 self.connection())
442 for attempt in (1, 2, 3):
443 try:
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:
449 raise
450
451 async def model_uuids(self):
452 """Return a mapping of model names to UUIDs.
453 """
454 controller_facade = client.ControllerFacade.from_connection(
455 self.connection())
456 for attempt in (1, 2, 3):
457 try:
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:
465 raise
466 await asyncio.sleep(attempt, loop=self._connector.loop)
467
468 async def list_models(self):
469 """Return list of names of the available models on this controller.
470
471 Equivalent to ``sorted((await self.model_uuids()).keys())``
472 """
473 uuids = await self.model_uuids()
474 return sorted(uuids.keys())
475
476 def get_payloads(self, *patterns):
477 """Return list of known payloads.
478
479 :param str *patterns: Patterns to match against
480
481 Each pattern will be checked against the following info in Juju::
482
483 - unit name
484 - machine id
485 - payload type
486 - payload class
487 - payload id
488 - payload tag
489 - payload status
490
491 """
492 raise NotImplementedError()
493
494 def login(self):
495 """Log in to this controller.
496
497 """
498 raise NotImplementedError()
499
500 def logout(self, force=False):
501 """Log out of this controller.
502
503 :param bool force: Don't fail even if user not previously logged in
504 with a password
505
506 """
507 raise NotImplementedError()
508
509 async def get_model(self, model):
510 """Get a model by name or UUID.
511
512 :param str model: Model name or UUID
513 :returns Model: Connected Model instance.
514 """
515 uuids = await self.model_uuids()
516 if model in uuids:
517 uuid = uuids[model]
518 else:
519 uuid = model
520
521 from juju.model import Model
522 model = Model()
523 kwargs = self.connection().connect_params()
524 kwargs['uuid'] = uuid
525 await model._connect_direct(**kwargs)
526 return model
527
528 async def get_user(self, username, secret_key=None):
529 """Get a user by name.
530
531 :param str username: Username
532 :param str secret_key: Issued by juju when add or reset user
533 password
534 :returns: A :class:`~juju.user.User` instance
535 """
536 client_facade = client.UserManagerFacade.from_connection(
537 self.connection())
538 user = tag.user(username)
539 args = [client.Entity(user)]
540 try:
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
546 return None
547 raise
548 if response.results and response.results[0].result:
549 return User(self, response.results[0].result, secret_key=secret_key)
550 return None
551
552 async def get_users(self, include_disabled=False):
553 """Return list of users that can connect to this controller.
554
555 :param bool include_disabled: Include disabled users
556 :returns: A list of :class:`~juju.user.User` instances
557 """
558 client_facade = client.UserManagerFacade.from_connection(
559 self.connection())
560 response = await client_facade.UserInfo(None, include_disabled)
561 return [User(self, r.result) for r in response.results]
562
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
567 remove permissions).
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.
572 """
573 controller_facade = client.ControllerFacade.from_connection(
574 self.connection())
575 user = tag.user(username)
576 changes = client.ModifyControllerAccess(acl, 'grant', user)
577 try:
578 await controller_facade.ModifyControllerAccess([changes])
579 return True
580 except errors.JujuError as e:
581 if 'user already has' in str(e):
582 return False
583 else:
584 raise
585
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.
591
592 :param str username: username
593 :param str acl: Access to remove ('login', 'add-model' or 'superuser')
594 """
595 controller_facade = client.ControllerFacade.from_connection(
596 self.connection())
597 user = tag.user(username)
598 changes = client.ModifyControllerAccess('login', 'revoke', user)
599 return await controller_facade.ModifyControllerAccess([changes])
600
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
605 permissions).
606
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')
610 """
611 model_facade = client.ModelManagerFacade.from_connection(
612 self.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])
617
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.
623
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')
627 """
628 model_facade = client.ModelManagerFacade.from_connection(
629 self.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])