Enhancements K8s helm connector
[osm/N2VC.git] / modules / libjuju / juju / controller.py
1 # Copyright 2019 Canonical Ltd.
2 #
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
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
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.
14 import asyncio
15 import json
16 import logging
17 from pathlib import Path
18
19 from . import errors, tag, utils
20 from .client import client, connector
21 from .user import User
22
23 log = logging.getLogger(__name__)
24
25
26 class Controller:
27 def __init__(
28 self,
29 loop=None,
30 max_frame_size=None,
31 bakery_client=None,
32 jujudata=None,
33 ):
34 """Instantiate a new Controller.
35
36 One of the connect_* methods will need to be called before this
37 object can be used for anything interesting.
38
39 If jujudata is None, jujudata.FileJujuData will be used.
40
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
47 information.
48 """
49 self._connector = connector.Connector(
50 loop=loop,
51 max_frame_size=max_frame_size,
52 bakery_client=bakery_client,
53 jujudata=jujudata,
54 )
55
56 async def __aenter__(self):
57 await self.connect()
58 return self
59
60 async def __aexit__(self, exc_type, exc, tb):
61 await self.disconnect()
62
63 @property
64 def loop(self):
65 return self._connector.loop
66
67 async def connect(self, *args, **kwargs):
68 """Connect to a Juju controller.
69
70 This supports two calling conventions:
71
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
75 ``endpoint`` is not.
76
77 Otherwise, both the ``endpoint`` and authentication information
78 (``username`` and ``password``, or ``bakery_client`` and/or
79 ``macaroons``) are required.
80
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``.
84
85 Available parameters are:
86
87 :param str controller_name: Name of controller registered with the
88 Juju CLI.
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
94 (PEM formatted).
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
100 ``bakery_client``.
101 :param asyncio.BaseEventLoop loop: The event loop to use for async
102 operations.
103 :param int max_frame_size: The maximum websocket frame size to allow.
104 """
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 '
109 'controller_name')
110 elif args:
111 controller_name = args[0]
112 else:
113 controller_name = kwargs.pop('controller_name', None)
114 await self._connector.connect_controller(controller_name, **kwargs)
115 else:
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')
127 arg_names = [
128 'endpoint',
129 'username',
130 'password',
131 'cacert',
132 'bakery_client',
133 'macaroons',
134 'loop',
135 'max_frame_size',
136 ]
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)
147
148 async def connect_current(self):
149 """
150 .. deprecated:: 0.7.3
151 Use :meth:`.connect()` instead.
152 """
153 return await self.connect()
154
155 async def connect_controller(self, controller_name):
156 """
157 .. deprecated:: 0.7.3
158 Use :meth:`.connect(controller_name)` instead.
159 """
160 return await self.connect(controller_name)
161
162 async def _connect_direct(self, **kwargs):
163 await self.disconnect()
164 await self._connector.connect(**kwargs)
165
166 def is_connected(self):
167 """Reports whether the Controller is currently connected."""
168 return self._connector.is_connected()
169
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()
174
175 @property
176 def controller_name(self):
177 return self._connector.controller_name
178
179 async def disconnect(self):
180 """Shut down the watcher task and close websockets.
181
182 """
183 await self._connector.disconnect()
184
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.
188
189 :param str name: Name of new credential. If None, the default
190 local credential is used. Name must be provided if a credential
191 is given.
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
197 the current user.
198 :param bool force: Force indicates whether the update should be forced.
199 It's only supported for facade v3 or later.
200 Defaults to false.
201 :returns: Name of credential that was uploaded.
202 """
203 if not cloud:
204 cloud = await self.get_cloud()
205
206 if not owner:
207 owner = self.connection().info['user-info']['identity']
208
209 if credential and not name:
210 raise errors.JujuError('Name must be provided for credential')
211
212 if not credential:
213 name, credential = self._connector.jujudata.load_credential(cloud,
214 name)
215 if credential is None:
216 raise errors.JujuError(
217 'Unable to find credential: {}'.format(name))
218
219 if credential.auth_type == 'jsonfile' and 'file' in credential.attrs:
220 # file creds have to be loaded before being sent to the controller
221 try:
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():
228 # make a copy
229 cred_json = credential.to_json()
230 credential = client.CloudCredential.from_json(cred_json)
231 # inline the cred
232 credential.attrs['file'] = cred_path.read_text()
233
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,
240 )]
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,
246 )
247 else:
248 await cloud_facade.UpdateCredentials(tagged_credentials)
249 return name
250
251 async def add_model(
252 self, model_name, cloud_name=None, credential_name=None,
253 owner=None, config=None, region=None):
254 """Add a model to this controller.
255
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
261 default credential.
262 :param str owner: Username that will own the model. Defaults to
263 the current user.
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.
267 """
268 model_facade = client.ModelManagerFacade.from_connection(
269 self.connection())
270
271 owner = owner or self.connection().info['user-info']['identity']
272 cloud_name = cloud_name or await self.get_cloud()
273
274 try:
275 # attempt to add/update the credential from local data if available
276 credential_name = await self.add_credential(
277 name=credential_name,
278 cloud=cloud_name,
279 owner=owner)
280 except errors.JujuError:
281 # if it's not available locally, assume it's on the controller
282 pass
283
284 if credential_name:
285 credential = tag.credential(
286 cloud_name,
287 tag.untag('user-', owner),
288 credential_name
289 )
290 else:
291 credential = None
292
293 log.debug('Creating model %s', model_name)
294
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)
299
300 model_info = await model_facade.CreateModel(
301 tag.cloud(cloud_name),
302 config,
303 credential,
304 model_name,
305 owner,
306 region
307 )
308
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.
312 import time
313 time.sleep(5)
314
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)
320
321 return model
322
323 async def destroy_models(self, *models, destroy_storage=False):
324 """Destroy one or more models.
325
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.
329
330 """
331 uuids = await self.model_uuids()
332 models = [uuids[model] if model in uuids else model
333 for model in models]
334
335 model_facade = client.ModelManagerFacade.from_connection(
336 self.connection())
337
338 log.debug(
339 'Destroying model%s %s',
340 '' if len(models) == 1 else 's',
341 ', '.join(models)
342 )
343
344 if model_facade.version >= 5:
345 params = [
346 client.DestroyModelParams(model_tag=tag.model(model),
347 destroy_storage=destroy_storage)
348 for model in models]
349 else:
350 params = [client.Entity(tag.model(model)) for model in models]
351
352 await model_facade.DestroyModels(params)
353 destroy_model = destroy_models
354
355 async def add_user(self, username, password=None, display_name=None):
356 """Add a user to this controller.
357
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
362 """
363 if not display_name:
364 display_name = username
365 user_facade = client.UserManagerFacade.from_connection(
366 self.connection())
367 users = [client.AddUser(display_name=display_name,
368 username=username,
369 password=password)]
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)
373
374 async def remove_user(self, username):
375 """Remove a user from this controller.
376 """
377 client_facade = client.UserManagerFacade.from_connection(
378 self.connection())
379 user = tag.user(username)
380 await client_facade.RemoveUser([client.Entity(user)])
381
382 async def change_user_password(self, username, password):
383 """Change the password for a user in this controller.
384
385 :param str username: Username
386 :param str password: New password
387
388 """
389 user_facade = client.UserManagerFacade.from_connection(
390 self.connection())
391 entity = client.EntityPassword(password, tag.user(username))
392 return await user_facade.SetPassword([entity])
393
394 async def reset_user_password(self, username):
395 """Reset user password.
396
397 :param str username: Username
398 :returns: A :class:`~juju.user.User` instance
399 """
400 user_facade = client.UserManagerFacade.from_connection(
401 self.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)
406
407 async def destroy(self, destroy_all_models=False):
408 """Destroy this controller.
409
410 :param bool destroy_all_models: Destroy all hosted models in the
411 controller.
412
413 """
414 controller_facade = client.ControllerFacade.from_connection(
415 self.connection())
416 return await controller_facade.DestroyController(destroy_all_models)
417
418 async def disable_user(self, username):
419 """Disable a user.
420
421 :param str username: Username
422
423 """
424 user_facade = client.UserManagerFacade.from_connection(
425 self.connection())
426 entity = client.Entity(tag.user(username))
427 return await user_facade.DisableUser([entity])
428
429 async def enable_user(self, username):
430 """Re-enable a previously disabled user.
431
432 """
433 user_facade = client.UserManagerFacade.from_connection(
434 self.connection())
435 entity = client.Entity(tag.user(username))
436 return await user_facade.EnableUser([entity])
437
438 def kill(self):
439 """Forcibly terminate all machines and other associated resources for
440 this controller.
441
442 """
443 raise NotImplementedError()
444
445 async def get_cloud(self):
446 """
447 Get the name of the cloud that this controller lives on.
448 """
449 cloud_facade = client.CloudFacade.from_connection(self.connection())
450
451 result = await cloud_facade.Clouds()
452 cloud = list(result.clouds.keys())[0] # only lives on one cloud
453 return tag.untag('cloud-', cloud)
454
455 async def get_models(self, all_=False, username=None):
456 """
457 .. deprecated:: 0.7.0
458 Use :meth:`.list_models` instead.
459 """
460 controller_facade = client.ControllerFacade.from_connection(
461 self.connection())
462 for attempt in (1, 2, 3):
463 try:
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:
469 raise
470
471 async def model_uuids(self):
472 """Return a mapping of model names to UUIDs.
473 """
474 controller_facade = client.ControllerFacade.from_connection(
475 self.connection())
476 for attempt in (1, 2, 3):
477 try:
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:
485 raise
486 await asyncio.sleep(attempt, loop=self._connector.loop)
487
488 async def list_models(self):
489 """Return list of names of the available models on this controller.
490
491 Equivalent to ``sorted((await self.model_uuids()).keys())``
492 """
493 uuids = await self.model_uuids()
494 return sorted(uuids.keys())
495
496 def get_payloads(self, *patterns):
497 """Return list of known payloads.
498
499 :param str *patterns: Patterns to match against
500
501 Each pattern will be checked against the following info in Juju::
502
503 - unit name
504 - machine id
505 - payload type
506 - payload class
507 - payload id
508 - payload tag
509 - payload status
510
511 """
512 raise NotImplementedError()
513
514 def login(self):
515 """Log in to this controller.
516
517 """
518 raise NotImplementedError()
519
520 def logout(self, force=False):
521 """Log out of this controller.
522
523 :param bool force: Don't fail even if user not previously logged in
524 with a password
525
526 """
527 raise NotImplementedError()
528
529 async def get_model(self, model):
530 """Get a model by name or UUID.
531
532 :param str model: Model name or UUID
533 :returns Model: Connected Model instance.
534 """
535 uuids = await self.model_uuids()
536 if model in uuids:
537 uuid = uuids[model]
538 else:
539 uuid = model
540
541 from juju.model import Model
542 model = Model()
543 kwargs = self.connection().connect_params()
544 kwargs['uuid'] = uuid
545 await model._connect_direct(**kwargs)
546 return model
547
548 async def get_user(self, username, secret_key=None):
549 """Get a user by name.
550
551 :param str username: Username
552 :param str secret_key: Issued by juju when add or reset user
553 password
554 :returns: A :class:`~juju.user.User` instance
555 """
556 client_facade = client.UserManagerFacade.from_connection(
557 self.connection())
558 user = tag.user(username)
559 args = [client.Entity(user)]
560 try:
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
566 return None
567 raise
568 if response.results and response.results[0].result:
569 return User(self, response.results[0].result, secret_key=secret_key)
570 return None
571
572 async def get_users(self, include_disabled=False):
573 """Return list of users that can connect to this controller.
574
575 :param bool include_disabled: Include disabled users
576 :returns: A list of :class:`~juju.user.User` instances
577 """
578 client_facade = client.UserManagerFacade.from_connection(
579 self.connection())
580 response = await client_facade.UserInfo(None, include_disabled)
581 return [User(self, r.result) for r in response.results]
582
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
587 remove permissions).
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.
592 """
593 controller_facade = client.ControllerFacade.from_connection(
594 self.connection())
595 user = tag.user(username)
596 changes = client.ModifyControllerAccess(acl, 'grant', user)
597 try:
598 await controller_facade.ModifyControllerAccess([changes])
599 return True
600 except errors.JujuError as e:
601 if 'user already has' in str(e):
602 return False
603 else:
604 raise
605
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.
611
612 :param str username: username
613 :param str acl: Access to remove ('login', 'add-model' or 'superuser')
614 """
615 controller_facade = client.ControllerFacade.from_connection(
616 self.connection())
617 user = tag.user(username)
618 changes = client.ModifyControllerAccess('login', 'revoke', user)
619 return await controller_facade.ModifyControllerAccess([changes])
620
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
625 permissions).
626
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')
630 """
631 model_facade = client.ModelManagerFacade.from_connection(
632 self.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])
637
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.
643
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')
647 """
648 model_facade = client.ModelManagerFacade.from_connection(
649 self.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])