Fix bug 564
[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 information.
34 """
35 self._connector = connector.Connector(
36 loop=loop,
37 max_frame_size=max_frame_size,
38 bakery_client=bakery_client,
39 jujudata=jujudata,
40 )
41
42 async def __aenter__(self):
43 await self.connect()
44 return self
45
46 async def __aexit__(self, exc_type, exc, tb):
47 await self.disconnect()
48
49 @property
50 def loop(self):
51 return self._connector.loop
52
53 async def connect(self, *args, **kwargs):
54 """Connect to a Juju controller.
55
56 This supports two calling conventions:
57
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
61 ``endpoint`` is not.
62
63 Otherwise, both the ``endpoint`` and authentication information
64 (``username`` and ``password``, or ``bakery_client`` and/or
65 ``macaroons``) are required.
66
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``.
70
71 Available parameters are:
72
73 :param str controller_name: Name of controller registered with the
74 Juju CLI.
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
80 (PEM formatted).
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
86 ``bakery_client``.
87 :param asyncio.BaseEventLoop loop: The event loop to use for async
88 operations.
89 :param int max_frame_size: The maximum websocket frame size to allow.
90 """
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 '
95 'controller_name')
96 elif args:
97 controller_name = args[0]
98 else:
99 controller_name = kwargs.pop('controller_name', None)
100 await self._connector.connect_controller(controller_name, **kwargs)
101 else:
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')
113 arg_names = [
114 'endpoint',
115 'username',
116 'password',
117 'cacert',
118 'bakery_client',
119 'macaroons',
120 'loop',
121 'max_frame_size',
122 ]
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)
133
134 async def connect_current(self):
135 """
136 .. deprecated:: 0.7.3
137 Use :meth:`.connect()` instead.
138 """
139 return await self.connect()
140
141 async def connect_controller(self, controller_name):
142 """
143 .. deprecated:: 0.7.3
144 Use :meth:`.connect(controller_name)` instead.
145 """
146 return await self.connect(controller_name)
147
148 async def _connect_direct(self, **kwargs):
149 await self.disconnect()
150 await self._connector.connect(**kwargs)
151
152 def is_connected(self):
153 """Reports whether the Controller is currently connected."""
154 return self._connector.is_connected()
155
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()
160
161 @property
162 def controller_name(self):
163 return self._connector.controller_name
164
165 async def disconnect(self):
166 """Shut down the watcher task and close websockets.
167
168 """
169 await self._connector.disconnect()
170
171 async def add_credential(self, name=None, credential=None, cloud=None,
172 owner=None):
173 """Add or update a credential to the controller.
174
175 :param str name: Name of new credential. If None, the default
176 local credential is used. Name must be provided if a credential
177 is given.
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
183 the current user.
184 :returns: Name of credential that was uploaded.
185 """
186 if not cloud:
187 cloud = await self.get_cloud()
188
189 if not owner:
190 owner = self.connection().info['user-info']['identity']
191
192 if credential and not name:
193 raise errors.JujuError('Name must be provided for credential')
194
195 if not 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))
200
201 if credential.auth_type == 'jsonfile' and 'file' in credential.attrs:
202 # file creds have to be loaded before being sent to the controller
203 try:
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():
210 # make a copy
211 cred_json = credential.to_json()
212 credential = client.CloudCredential.from_json(cred_json)
213 # inline the cred
214 credential.attrs['file'] = cred_path.read_text()
215
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,
222 )])
223
224 return name
225
226 async def add_model(
227 self, model_name, cloud_name=None, credential_name=None,
228 owner=None, config=None, region=None):
229 """Add a model to this controller.
230
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
236 default credential.
237 :param str owner: Username that will own the model. Defaults to
238 the current user.
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.
242 """
243 model_facade = client.ModelManagerFacade.from_connection(
244 self.connection())
245
246 owner = owner or self.connection().info['user-info']['identity']
247 cloud_name = cloud_name or await self.get_cloud()
248
249 try:
250 # attempt to add/update the credential from local data if available
251 credential_name = await self.add_credential(
252 name=credential_name,
253 cloud=cloud_name,
254 owner=owner)
255 except errors.JujuError:
256 # if it's not available locally, assume it's on the controller
257 pass
258
259 if credential_name:
260 credential = tag.credential(
261 cloud_name,
262 tag.untag('user-', owner),
263 credential_name
264 )
265 else:
266 credential = None
267
268 log.debug('Creating model %s', model_name)
269
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)
274
275 model_info = await model_facade.CreateModel(
276 tag.cloud(cloud_name),
277 config,
278 credential,
279 model_name,
280 owner,
281 region
282 )
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)
288
289 return model
290
291 async def destroy_models(self, *models):
292 """Destroy one or more models.
293
294 :param str \*models: Names or UUIDs of models to destroy
295
296 """
297 uuids = await self.model_uuids()
298 models = [uuids[model] if model in uuids else model
299 for model in models]
300
301 model_facade = client.ModelManagerFacade.from_connection(
302 self.connection())
303
304 log.debug(
305 'Destroying model%s %s',
306 '' if len(models) == 1 else 's',
307 ', '.join(models)
308 )
309
310 await model_facade.DestroyModels([
311 client.Entity(tag.model(model))
312 for model in models
313 ])
314 destroy_model = destroy_models
315
316 async def add_user(self, username, password=None, display_name=None):
317 """Add a user to this controller.
318
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
323 """
324 if not display_name:
325 display_name = username
326 user_facade = client.UserManagerFacade.from_connection(self.connection())
327 users = [client.AddUser(display_name=display_name,
328 username=username,
329 password=password)]
330 await user_facade.AddUser(users)
331 return await self.get_user(username)
332
333 async def remove_user(self, username):
334 """Remove a user from this controller.
335 """
336 client_facade = client.UserManagerFacade.from_connection(
337 self.connection())
338 user = tag.user(username)
339 await client_facade.RemoveUser([client.Entity(user)])
340
341 async def change_user_password(self, username, password):
342 """Change the password for a user in this controller.
343
344 :param str username: Username
345 :param str password: New password
346
347 """
348 user_facade = client.UserManagerFacade.from_connection(self.connection())
349 entity = client.EntityPassword(password, tag.user(username))
350 return await user_facade.SetPassword([entity])
351
352 async def destroy(self, destroy_all_models=False):
353 """Destroy this controller.
354
355 :param bool destroy_all_models: Destroy all hosted models in the
356 controller.
357
358 """
359 controller_facade = client.ControllerFacade.from_connection(
360 self.connection())
361 return await controller_facade.DestroyController(destroy_all_models)
362
363 async def disable_user(self, username):
364 """Disable a user.
365
366 :param str username: Username
367
368 """
369 user_facade = client.UserManagerFacade.from_connection(self.connection())
370 entity = client.Entity(tag.user(username))
371 return await user_facade.DisableUser([entity])
372
373 async def enable_user(self, username):
374 """Re-enable a previously disabled user.
375
376 """
377 user_facade = client.UserManagerFacade.from_connection(self.connection())
378 entity = client.Entity(tag.user(username))
379 return await user_facade.EnableUser([entity])
380
381 def kill(self):
382 """Forcibly terminate all machines and other associated resources for
383 this controller.
384
385 """
386 raise NotImplementedError()
387
388 async def get_cloud(self):
389 """
390 Get the name of the cloud that this controller lives on.
391 """
392 cloud_facade = client.CloudFacade.from_connection(self.connection())
393
394 result = await cloud_facade.Clouds()
395 cloud = list(result.clouds.keys())[0] # only lives on one cloud
396 return tag.untag('cloud-', cloud)
397
398 async def get_models(self, all_=False, username=None):
399 """
400 .. deprecated:: 0.7.0
401 Use :meth:`.list_models` instead.
402 """
403 controller_facade = client.ControllerFacade.from_connection(
404 self.connection())
405 for attempt in (1, 2, 3):
406 try:
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:
412 raise
413
414 async def model_uuids(self):
415 """Return a mapping of model names to UUIDs.
416 """
417 controller_facade = client.ControllerFacade.from_connection(
418 self.connection())
419 for attempt in (1, 2, 3):
420 try:
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:
428 raise
429 await asyncio.sleep(attempt, loop=self._connector.loop)
430
431 async def list_models(self):
432 """Return list of names of the available models on this controller.
433
434 Equivalent to ``sorted((await self.model_uuids()).keys())``
435 """
436 uuids = await self.model_uuids()
437 return sorted(uuids.keys())
438
439 def get_payloads(self, *patterns):
440 """Return list of known payloads.
441
442 :param str \*patterns: Patterns to match against
443
444 Each pattern will be checked against the following info in Juju::
445
446 - unit name
447 - machine id
448 - payload type
449 - payload class
450 - payload id
451 - payload tag
452 - payload status
453
454 """
455 raise NotImplementedError()
456
457 def login(self):
458 """Log in to this controller.
459
460 """
461 raise NotImplementedError()
462
463 def logout(self, force=False):
464 """Log out of this controller.
465
466 :param bool force: Don't fail even if user not previously logged in
467 with a password
468
469 """
470 raise NotImplementedError()
471
472 async def get_model(self, model):
473 """Get a model by name or UUID.
474
475 :param str model: Model name or UUID
476 :returns Model: Connected Model instance.
477 """
478 uuids = await self.model_uuids()
479 if model in uuids:
480 uuid = uuids[model]
481 else:
482 uuid = model
483
484 from juju.model import Model
485 model = Model()
486 kwargs = self.connection().connect_params()
487 kwargs['uuid'] = uuid
488 await model._connect_direct(**kwargs)
489 return model
490
491 async def get_user(self, username):
492 """Get a user by name.
493
494 :param str username: Username
495 :returns: A :class:`~juju.user.User` instance
496 """
497 client_facade = client.UserManagerFacade.from_connection(
498 self.connection())
499 user = tag.user(username)
500 args = [client.Entity(user)]
501 try:
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
507 return None
508 raise
509 if response.results and response.results[0].result:
510 return User(self, response.results[0].result)
511 return None
512
513 async def get_users(self, include_disabled=False):
514 """Return list of users that can connect to this controller.
515
516 :param bool include_disabled: Include disabled users
517 :returns: A list of :class:`~juju.user.User` instances
518 """
519 client_facade = client.UserManagerFacade.from_connection(
520 self.connection())
521 response = await client_facade.UserInfo(None, include_disabled)
522 return [User(self, r.result) for r in response.results]
523
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
528 remove permissions).
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.
533 """
534 controller_facade = client.ControllerFacade.from_connection(
535 self.connection())
536 user = tag.user(username)
537 changes = client.ModifyControllerAccess(acl, 'grant', user)
538 try:
539 await controller_facade.ModifyControllerAccess([changes])
540 return True
541 except errors.JujuError as e:
542 if 'user already has' in str(e):
543 return False
544 else:
545 raise
546
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.
552
553 :param str username: username
554 :param str acl: Access to remove ('login', 'add-model' or 'superuser')
555 """
556 controller_facade = client.ControllerFacade.from_connection(
557 self.connection())
558 user = tag.user(username)
559 changes = client.ModifyControllerAccess('login', 'revoke', user)
560 return await controller_facade.ModifyControllerAccess([changes])
561
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).
566
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')
570 """
571 model_facade = client.ModelManagerFacade.from_connection(
572 self.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])
577
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.
583
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')
587 """
588 model_facade = client.ModelManagerFacade.from_connection(
589 self.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])