Full Juju Charm support
[osm/SO.git] / common / python / rift / mano / utils / juju_api.py
1 ############################################################################
2 # Copyright 2016 RIFT.io Inc #
3 # #
4 # Licensed under the Apache License, Version 2.0 (the "License"); #
5 # you may not use this file except in compliance with the License. #
6 # You may obtain a copy of the License at #
7 # #
8 # http://www.apache.org/licenses/LICENSE-2.0 #
9 # #
10 # Unless required by applicable law or agreed to in writing, software #
11 # distributed under the License is distributed on an "AS IS" BASIS, #
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
13 # See the License for the specific language governing permissions and #
14 # limitations under the License. #
15 ############################################################################
16
17 import argparse
18 import asyncio
19 import logging
20 import os
21 import ssl
22
23 import juju.loop
24 from juju.controller import Controller
25 from juju.model import Model, ModelObserver
26
27 try:
28 ssl._create_default_https_context = ssl._create_unverified_context
29 except AttributeError:
30 # Legacy Python doesn't verify by default (see pep-0476)
31 # https://www.python.org/dev/peps/pep-0476/
32 pass
33
34
35 class JujuVersionError(Exception):
36 pass
37
38
39 class JujuApiError(Exception):
40 pass
41
42
43 class JujuEnvError(JujuApiError):
44 pass
45
46
47 class JujuModelError(JujuApiError):
48 pass
49
50
51 class JujuStatusError(JujuApiError):
52 pass
53
54
55 class JujuUnitsError(JujuApiError):
56 pass
57
58
59 class JujuWaitUnitsError(JujuApiError):
60 pass
61
62
63 class JujuSrvNotDeployedError(JujuApiError):
64 pass
65
66
67 class JujuAddCharmError(JujuApiError):
68 pass
69
70
71 class JujuDeployError(JujuApiError):
72 pass
73
74
75 class JujuDestroyError(JujuApiError):
76 pass
77
78
79 class JujuResolveError(JujuApiError):
80 pass
81
82
83 class JujuActionError(JujuApiError):
84 pass
85
86
87 class JujuActionApiError(JujuActionError):
88 pass
89
90
91 class JujuActionInfoError(JujuActionError):
92 pass
93
94
95 class JujuActionExecError(JujuActionError):
96 pass
97
98
99 class JujuAuthenticationError(Exception):
100 pass
101
102
103 class JujuMonitor(ModelObserver):
104 """Monitor state changes within the Juju Model."""
105 # async def on_change(self, delta, old, new, model):
106 # """React to changes in the Juju model."""
107 #
108 # # TODO: Setup the hook to update the UI if the status of a unit changes
109 # # to be used when deploying a charm and waiting for it to be "ready"
110 # if delta.entity in ['application', 'unit'] and delta.type == "change":
111 # pass
112 #
113 # # TODO: Add a hook when an action is complete
114
115 pass
116
117
118 class JujuApi(object):
119 """JujuApi wrapper on jujuclient library.
120
121 There should be one instance of JujuApi for each VNF manged by Juju.
122
123 Assumption:
124 Currently we use one unit per service/VNF. So once a service
125 is deployed, we store the unit name and reuse it
126 """
127
128 log = None
129 controller = None
130 models = {}
131 model = None
132 model_name = None
133 model_uuid = None
134 authenticated = False
135
136 def __init__(self,
137 log=None,
138 loop=None,
139 server='127.0.0.1',
140 port=17070,
141 user='admin',
142 secret=None,
143 version=None,
144 model_name='default',
145 ):
146 """Initialize with the Juju credentials."""
147
148 if log:
149 self.log = log
150 else:
151 self.log = logging.getLogger(__name__)
152
153 # Quiet websocket traffic
154 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
155
156 self.log.debug('JujuApi: instantiated')
157
158 self.server = server
159 self.port = port
160
161 self.secret = secret
162 if user.startswith('user-'):
163 self.user = user
164 else:
165 self.user = 'user-{}'.format(user)
166
167 self.endpoint = '%s:%d' % (server, int(port))
168
169 self.model_name = model_name
170
171 if loop:
172 self.loop = loop
173
174 def __del__(self):
175 """Close any open connections."""
176 yield self.logout()
177
178 async def add_relation(self, a, b, via=None):
179 """
180 Add a relation between two application endpoints.
181
182 :param a An application endpoint
183 :param b An application endpoint
184 :param via The egress subnet(s) for outbound traffic, e.g., (192.168.0.0/16,10.0.0.0/8)
185 """
186 if not self.authenticated:
187 await self.login()
188
189 m = await self.get_model()
190 try:
191 m.add_relation(a, b, via)
192 finally:
193 await m.disconnect()
194
195 async def apply_config(self, config, application):
196 """Apply a configuration to the application."""
197 self.log.debug("JujuApi: Applying configuration to {}.".format(
198 application
199 ))
200 return await self.set_config(application=application, config=config)
201
202 async def deploy_application(self, charm, name="", path="", specs={}):
203 """
204 Deploy an application.
205
206 Deploy an application to a container or a machine already provisioned
207 by the OSM Resource Orchestrator (requires the Juju public ssh key
208 installed on the new machine via cloud-init).
209
210 :param str charm: The name of the charm
211 :param str name: The name of the application, if different than the charm
212 :param str path: The path to the charm
213 :param dict machine: A dictionary identifying the machine to manage via Juju
214 Examples::
215
216 deploy_application(..., specs={'host': '10.0.0.4', 'user': 'ubuntu'})
217 """
218 if not self.authenticated:
219 await self.login()
220
221 # Check that the charm is valid and exists.
222 if charm is None:
223 return None
224
225 app = await self.get_application(name)
226 if app is None:
227
228 # Check for specific machine placement
229 to = None
230 if all(k in specs for k in ['hostname', 'username']):
231 machine = await self.model.add_machine(spec='ssh:%@%'.format(
232 specs['host'],
233 specs['user'],
234 ))
235 to = machine.id
236
237 # TODO: Handle the error if the charm isn't found.
238 self.log.debug("JujuApi: Deploying charm {} ({}) from {}".format(
239 charm,
240 name,
241 path,
242 to=to,
243 ))
244 app = await self.model.deploy(
245 path,
246 application_name=name,
247 series='xenial',
248 )
249 return app
250 deploy_service = deploy_application
251
252 async def get_action_status(self, uuid):
253 """Get the status of an action."""
254 if not self.authenticated:
255 await self.login()
256
257 self.log.debug("JujuApi: Waiting for status of action uuid {}".format(uuid))
258 action = await self.model.wait_for_action(uuid)
259 return action.status
260
261 async def get_application(self, application):
262 """Get the deployed application."""
263 if not self.authenticated:
264 await self.login()
265
266 self.log.debug("JujuApi: Getting application {}".format(application))
267 app = None
268 if application and self.model:
269 if self.model.applications:
270 if application in self.model.applications:
271 app = self.model.applications[application]
272 return app
273
274 async def get_application_status(self, application):
275 """Get the status of an application."""
276 if not self.authenticated:
277 await self.login()
278
279 status = None
280 app = await self.get_application(application)
281 if app:
282 status = app.status
283 self.log.debug("JujuApi: Status of application {} is {}".format(
284 application,
285 str(status),
286 ))
287 return status
288 get_service_status = get_application_status
289
290 async def get_config(self, application):
291 """Get the configuration of an application."""
292 if not self.authenticated:
293 await self.login()
294
295 config = None
296 app = await self.get_application(application)
297 if app:
298 config = await app.get_config()
299
300 self.log.debug("JujuApi: Config of application {} is {}".format(
301 application,
302 str(config),
303 ))
304
305 return config
306
307 async def get_model(self, name='default'):
308 """Get a model from the Juju Controller.
309
310 Note: Model objects returned must call disconnected() before it goes
311 out of scope."""
312 if not self.authenticated:
313 await self.login()
314
315 model = Model()
316
317 uuid = await self.get_model_uuid(name)
318
319 self.log.debug("JujuApi: Connecting to model {} ({})".format(
320 model,
321 uuid,
322 ))
323
324 await model.connect(
325 self.endpoint,
326 uuid,
327 self.user,
328 self.secret,
329 None,
330 )
331
332 return model
333
334 async def get_model_uuid(self, name='default'):
335 """Get the UUID of a model.
336
337 Iterate through all models in a controller and find the matching model.
338 """
339 if not self.authenticated:
340 await self.login()
341
342 uuid = None
343
344 models = await self.controller.get_models()
345
346 self.log.debug("JujuApi: Looking through {} models for model {}".format(
347 len(models.user_models),
348 name,
349 ))
350 for model in models.user_models:
351 if model.model.name == name:
352 uuid = model.model.uuid
353 break
354
355 return uuid
356
357 async def get_status(self):
358 """Get the model status."""
359 if not self.authenticated:
360 await self.login()
361
362 if not self.model:
363 self.model = self.get_model(self.model_name)
364
365 class model_state:
366 applications = {}
367 machines = {}
368 relations = {}
369
370 self.log.debug("JujuApi: Getting model status")
371 status = model_state()
372 status.applications = self.model.applications
373 status.machines = self.model.machines
374
375 return status
376
377 async def is_application_active(self, application):
378 """Check if the application is in an active state."""
379 if not self.authenticated:
380 await self.login()
381
382 state = False
383 status = await self.get_application_status(application)
384 if status and status in ['active']:
385 state = True
386
387 self.log.debug("JujuApi: Application {} is {} active".format(
388 application,
389 "" if status else "not",
390 ))
391
392 return state
393 is_service_active = is_application_active
394
395 async def is_application_blocked(self, application):
396 """Check if the application is in a blocked state."""
397 if not self.authenticated:
398 await self.login()
399
400 state = False
401 status = await self.get_application_status(application)
402 if status and status in ['blocked']:
403 state = True
404
405 self.log.debug("JujuApi: Application {} is {} blocked".format(
406 application,
407 "" if status else "not",
408 ))
409
410 return state
411 is_service_blocked = is_application_blocked
412
413 async def is_application_deployed(self, application):
414 """Check if the application is in a deployed state."""
415 if not self.authenticated:
416 await self.login()
417
418 state = False
419 status = await self.get_application_status(application)
420 if status and status in ['active']:
421 state = True
422 self.log.debug("JujuApi: Application {} is {} deployed".format(
423 application,
424 "" if status else "not",
425 ))
426
427 return state
428 is_service_deployed = is_application_deployed
429
430 async def is_application_error(self, application):
431 """Check if the application is in an error state."""
432 if not self.authenticated:
433 await self.login()
434
435 state = False
436 status = await self.get_application_status(application)
437 if status and status in ['error']:
438 state = True
439 self.log.debug("JujuApi: Application {} is {} errored".format(
440 application,
441 "" if status else "not",
442 ))
443
444 return state
445 is_service_error = is_application_error
446
447 async def is_application_maint(self, application):
448 """Check if the application is in a maintenance state."""
449 if not self.authenticated:
450 await self.login()
451
452 state = False
453 status = await self.get_application_status(application)
454 if status and status in ['maintenance']:
455 state = True
456 self.log.debug("JujuApi: Application {} is {} in maintenence".format(
457 application,
458 "" if status else "not",
459 ))
460
461 return state
462 is_service_maint = is_application_maint
463
464 async def is_application_up(self, application=None):
465 """Check if the application is up."""
466 if not self.authenticated:
467 await self.login()
468 state = False
469
470 status = await self.get_application_status(application)
471 if status and status in ['active', 'blocked']:
472 state = True
473 self.log.debug("JujuApi: Application {} is {} up".format(
474 application,
475 "" if status else "not",
476 ))
477 return state
478 is_service_up = is_application_up
479
480 async def login(self):
481 """Login to the Juju controller."""
482 if self.authenticated:
483 return
484 cacert = None
485 self.controller = Controller()
486
487 self.log.debug("JujuApi: Logging into controller")
488
489 if self.secret:
490 await self.controller.connect(
491 self.endpoint,
492 self.user,
493 self.secret,
494 cacert,
495 )
496 else:
497 await self.controller.connect_current()
498
499 self.authenticated = True
500 self.model = await self.get_model(self.model_name)
501
502 async def logout(self):
503 """Logout of the Juju controller."""
504 if not self.authenticated:
505 return
506
507 if self.model:
508 await self.model.disconnect()
509 self.model = None
510 if self.controller:
511 await self.controller.disconnect()
512 self.controller = None
513
514 self.authenticated = False
515
516 async def remove_application(self, name):
517 """Remove the application."""
518 if not self.authenticated:
519 await self.login()
520
521 app = await self.get_application(name)
522 if app:
523 self.log.debug("JujuApi: Destroying application {}".format(
524 name,
525 ))
526
527 await app.destroy()
528
529 async def remove_relation(self, a, b):
530 """
531 Remove a relation between two application endpoints
532
533 :param a An application endpoint
534 :param b An application endpoint
535 """
536 if not self.authenticated:
537 await self.login()
538
539 m = await self.get_model()
540 try:
541 m.remove_relation(a, b)
542 finally:
543 await m.disconnect()
544
545 async def resolve_error(self, application=None):
546 """Resolve units in error state."""
547 if not self.authenticated:
548 await self.login()
549
550 app = await self.get_application(application)
551 if app:
552 self.log.debug("JujuApi: Resolving errors for application {}".format(
553 application,
554 ))
555
556 for unit in app.units:
557 app.resolved(retry=True)
558
559 async def run_action(self, application, action_name, **params):
560 """Execute an action and return an Action object."""
561 if not self.authenticated:
562 await self.login()
563 result = {
564 'status': '',
565 'action': {
566 'tag': None,
567 'results': None,
568 }
569 }
570 app = await self.get_application(application)
571 if app:
572 # We currently only have one unit per application
573 # so use the first unit available.
574 unit = app.units[0]
575
576 self.log.debug("JujuApi: Running Action {} against Application {}".format(
577 action_name,
578 application,
579 ))
580
581 action = await unit.run_action(action_name, **params)
582
583 # Wait for the action to complete
584 await action.wait()
585
586 result['status'] = action.status
587 result['action']['tag'] = action.data['id']
588 result['action']['results'] = action.results
589
590 return result
591 execute_action = run_action
592
593 async def set_config(self, application, config):
594 """Apply a configuration to the application."""
595 if not self.authenticated:
596 await self.login()
597
598 app = await self.get_application(application)
599 if app:
600 self.log.debug("JujuApi: Setting config for Application {}".format(
601 application,
602 ))
603 await app.set_config(config)
604
605 # Verify the config is set
606 newconf = await app.get_config()
607 for key in config:
608 if config[key] != newconf[key]:
609 self.log.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key, config[key], newconf[key]))
610
611
612 async def set_parameter(self, parameter, value, application=None):
613 """Set a config parameter for a service."""
614 if not self.authenticated:
615 await self.login()
616
617 self.log.debug("JujuApi: Setting {}={} for Application {}".format(
618 parameter,
619 value,
620 application,
621 ))
622 return await self.apply_config(
623 {parameter: value},
624 application=application,
625 )
626
627 async def wait_for_application(self, name, timeout=300):
628 """Wait for an application to become active."""
629 if not self.authenticated:
630 await self.login()
631
632 app = await self.get_application(name)
633 if app:
634 self.log.debug("JujuApi: Waiting {} seconds for Application {}".format(
635 timeout,
636 name,
637 ))
638
639 await self.model.block_until(
640 lambda: all(
641 unit.agent_status == 'idle'
642 and unit.workload_status
643 in ['active', 'unknown'] for unit in app.units
644 ),
645 timeout=timeout,
646 )
647
648
649 def get_argparser():
650 parser = argparse.ArgumentParser(description='Test Driver for Juju API')
651
652 ###################
653 # Authentication #
654 ###################
655 parser.add_argument(
656 "-s", "--server",
657 default='10.0.202.49',
658 help="Juju controller",
659 )
660 parser.add_argument(
661 "-u", "--user",
662 default='admin',
663 help="User, default user-admin"
664 )
665 parser.add_argument(
666 "-p", "--password",
667 default='',
668 help="Password for the user"
669 )
670 parser.add_argument(
671 "-P", "--port",
672 default=17070,
673 help="Port number, default 17070"
674 )
675 parser.add_argument(
676 "-m", "--model",
677 default='default',
678 help="The model to connect to."
679 )
680
681 ##########
682 # Charm #
683 ##########
684 parser.add_argument(
685 "-d", "--directory",
686 help="Local directory for the charm"
687 )
688 parser.add_argument(
689 "--application",
690 help="Charm name"
691 )
692
693 #############
694 # Placement #
695 #############
696
697 """
698 To deploy to a non-Juju machine, provide the host and
699 credentials for Juju to manually provision (host, username, (password or key?))
700
701 """
702 parser.add_argument(
703 "--proxy",
704 action='store_true',
705 help="Deploy as a proxy charm.",
706 )
707 parser.add_argument(
708 "--no-proxy",
709 action='store_false',
710 dest='proxy',
711 help="Deploy as a full charm.",
712 )
713 parser.set_defaults(proxy=True)
714
715 # Test options?
716 # unit test?
717 #######
718 # VNF #
719 #######
720
721 return parser.parse_args()
722
723
724 async def deploy_charm_and_wait():
725 args = get_argparser()
726
727 # Set logging level to debug so we can see verbose output from the
728 # juju library.
729 logging.basicConfig(level=logging.DEBUG)
730
731 # Quiet logging from the websocket library. If you want to see
732 # everything sent over the wire, set this to DEBUG.
733 ws_logger = logging.getLogger('websockets.protocol')
734 ws_logger.setLevel(logging.INFO)
735
736 """Here's an example of a coroutine that will deploy a charm and wait until
737 it's ready to be used."""
738 api = JujuApi(server=args.server,
739 port=args.port,
740 user=args.user,
741 secret=args.password,
742 # loop=loop,
743 log=ws_logger,
744 model_name=args.model
745 )
746 print("Logging in...")
747 await api.login()
748
749 if api.authenticated:
750 status = await api.get_status()
751 print('Applications:', list(status.applications.keys()))
752 print('Machines:', list(status.machines.keys()))
753
754 if args.directory and args.application:
755
756 # Deploy the charm
757 charm = os.path.basename(
758 os.path.expanduser(
759 os.path.dirname(args.directory)
760 )
761 )
762 await api.deploy_application(charm,
763 name=args.application,
764 path=args.directory,
765 )
766
767 # Wait for the application to fully deploy. This will block until the
768 # agent is in an idle state, and the charm's workload is either
769 # 'active' or 'unknown', meaning it's ready but the author did not
770 # explicitly set a workload state.
771 print("Waiting for application '{}' to deploy...".format(charm))
772 while (True):
773 # Deploy the charm and wait, periodically checking its status
774 await api.wait_for_application(charm, 30)
775
776 error = await api.is_application_error(charm)
777 if error:
778 print("This application is in an error state.")
779 break
780
781 blocked = await api.is_application_blocked(charm)
782 if blocked:
783 print("This application is blocked.")
784 break
785
786 # An extra check to see if the charm is ready
787 up = await api.is_application_up(charm)
788 print("Application is {}".format("up" if up else "down"))
789
790 print("Service {} is deployed".format(args.application))
791
792 ###################################
793 # Execute config on a proxy charm #
794 ###################################
795 config = await api.get_config(args.application)
796 hostname = config['ssh-username']['value']
797 rhostname = hostname[::-1]
798
799 # Apply the configuration
800 await api.apply_config(
801 {'ssh-username': rhostname}, application=args.application
802 )
803
804 # Get the configuration
805 config = await api.get_config(args.application)
806
807 # Verify the configuration has been updated
808 assert(config['ssh-username']['value'] == rhostname)
809
810 ####################################
811 # Get the status of an application #
812 ####################################
813 status = await api.get_application_status(charm)
814 print("Application Status: {}".format(status))
815
816 ###########################
817 # Execute a simple action #
818 ###########################
819 result = await api.run_action(charm, 'get-ssh-public-key')
820 print("Action {} status is {} and returned {}".format(
821 result['status'],
822 result['action']['tag'],
823 result['action']['results']
824 ))
825
826 #####################################
827 # Execute an action with parameters #
828 #####################################
829 result = await api.run_action(charm, 'run', command='hostname')
830
831 print("Action {} status is {} and returned {}".format(
832 result['status'],
833 result['action']['tag'],
834 result['action']['results']
835 ))
836
837 print("Logging out...")
838 await api.logout()
839 api = None
840
841 # get public key in juju controller? that can be pulled without need of a charm deployed and installed to vm via cloud-init
842
843 if __name__ == "__main__":
844 # Create a single event loop for running code asyncronously.
845 loop = asyncio.get_event_loop()
846
847 # An initial set of tasks to run
848 tasks = [
849 deploy_charm_and_wait(),
850 ]
851
852 # TODO: optionally run forever and use a Watcher to monitor what's happening
853 loop.run_until_complete(asyncio.wait(tasks))