blob: 9b8fa5adfa71ef86f7c4714af218b833063f940e [file] [log] [blame]
Adam Israelc887cc22018-02-05 13:52:15 +01001############################################################################
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
17import argparse
18import asyncio
19import logging
20import os
21import ssl
22
23import juju.loop
24from juju.controller import Controller
25from juju.model import Model, ModelObserver
26
27try:
28 ssl._create_default_https_context = ssl._create_unverified_context
29except 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
35class JujuVersionError(Exception):
36 pass
37
38
39class JujuApiError(Exception):
40 pass
41
42
43class JujuEnvError(JujuApiError):
44 pass
45
46
47class JujuModelError(JujuApiError):
48 pass
49
50
51class JujuStatusError(JujuApiError):
52 pass
53
54
55class JujuUnitsError(JujuApiError):
56 pass
57
58
59class JujuWaitUnitsError(JujuApiError):
60 pass
61
62
63class JujuSrvNotDeployedError(JujuApiError):
64 pass
65
66
67class JujuAddCharmError(JujuApiError):
68 pass
69
70
71class JujuDeployError(JujuApiError):
72 pass
73
74
75class JujuDestroyError(JujuApiError):
76 pass
77
78
79class JujuResolveError(JujuApiError):
80 pass
81
82
83class JujuActionError(JujuApiError):
84 pass
85
86
87class JujuActionApiError(JujuActionError):
88 pass
89
90
91class JujuActionInfoError(JujuActionError):
92 pass
93
94
95class JujuActionExecError(JujuActionError):
96 pass
97
98
99class JujuAuthenticationError(Exception):
100 pass
101
102
103class 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
118class 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
649def 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
724async 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
757
758 # Deploy the charm
759 charm = os.path.basename(
760 os.path.expanduser(
761 os.path.dirname(args.directory)
762 )
763 )
764 await api.deploy_application(charm,
765 name=args.application,
766 path=args.directory,
767 )
768
769 # Wait for the application to fully deploy. This will block until the
770 # agent is in an idle state, and the charm's workload is either
771 # 'active' or 'unknown', meaning it's ready but the author did not
772 # explicitly set a workload state.
773 print("Waiting for application '{}' to deploy...".format(charm))
774 while (True):
775 # Deploy the charm and wait, periodically checking its status
776 await api.wait_for_application(charm, 30)
777
778 error = await api.is_application_error(charm)
779 if error:
780 print("This application is in an error state.")
781 break
782
783 blocked = await api.is_application_blocked(charm)
784 if blocked:
785 print("This application is blocked.")
786 break
787
788 # An extra check to see if the charm is ready
789 up = await api.is_application_up(charm)
790 print("Application is {}".format("up" if up else "down"))
791
792 print("Service {} is deployed".format(args.application))
793
794 ###################################
795 # Execute config on a proxy charm #
796 ###################################
797 config = await api.get_config(args.application)
798 hostname = config['ssh-username']['value']
799 rhostname = hostname[::-1]
800
801 # Apply the configuration
802 await api.apply_config(
803 {'ssh-username': rhostname}, application=args.application
804 )
805
806 # Get the configuration
807 config = await api.get_config(args.application)
808
809 # Verify the configuration has been updated
810 assert(config['ssh-username']['value'] == rhostname)
811
812 ####################################
813 # Get the status of an application #
814 ####################################
815 status = await api.get_application_status(charm)
816 print("Application Status: {}".format(status))
817
818 ###########################
819 # Execute a simple action #
820 ###########################
821 result = await api.run_action(charm, 'get-ssh-public-key')
822 print("Action {} status is {} and returned {}".format(
823 result['status'],
824 result['action']['tag'],
825 result['action']['results']
826 ))
827
828 #####################################
829 # Execute an action with parameters #
830 #####################################
831 result = await api.run_action(charm, 'run', command='hostname')
832
833 print("Action {} status is {} and returned {}".format(
834 result['status'],
835 result['action']['tag'],
836 result['action']['results']
837 ))
838
839 print("Logging out...")
840 await api.logout()
841 api = None
842
843# get public key in juju controller? that can be pulled without need of a charm deployed and installed to vm via cloud-init
844
845if __name__ == "__main__":
846 # Create a single event loop for running code asyncronously.
847 loop = asyncio.get_event_loop()
848
849 # An initial set of tasks to run
850 tasks = [
851 deploy_charm_and_wait(),
852 ]
853
854 # TODO: optionally run forever and use a Watcher to monitor what's happening
855 loop.run_until_complete(asyncio.wait(tasks))