Fix bug 601
[osm/N2VC.git] / n2vc / vnf.py
1 import asyncio
2 import logging
3 import os
4 import os.path
5 import re
6 import shlex
7 import ssl
8 import subprocess
9 import sys
10 # import time
11
12 # FIXME: this should load the juju inside or modules without having to
13 # explicitly install it. Check why it's not working.
14 # Load our subtree of the juju library
15 path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
16 path = os.path.join(path, "modules/libjuju/")
17 if path not in sys.path:
18 sys.path.insert(1, path)
19
20 from juju.controller import Controller
21 from juju.model import ModelObserver
22 from juju.errors import JujuAPIError, JujuError
23
24 # We might need this to connect to the websocket securely, but test and verify.
25 try:
26 ssl._create_default_https_context = ssl._create_unverified_context
27 except AttributeError:
28 # Legacy Python doesn't verify by default (see pep-0476)
29 # https://www.python.org/dev/peps/pep-0476/
30 pass
31
32
33 # Custom exceptions
34 class JujuCharmNotFound(Exception):
35 """The Charm can't be found or is not readable."""
36
37
38 class JujuApplicationExists(Exception):
39 """The Application already exists."""
40
41
42 class N2VCPrimitiveExecutionFailed(Exception):
43 """Something failed while attempting to execute a primitive."""
44
45
46 class NetworkServiceDoesNotExist(Exception):
47 """The Network Service being acted against does not exist."""
48
49
50 # Quiet the debug logging
51 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
52 logging.getLogger('juju.client.connection').setLevel(logging.WARN)
53 logging.getLogger('juju.model').setLevel(logging.WARN)
54 logging.getLogger('juju.machine').setLevel(logging.WARN)
55
56
57 class VCAMonitor(ModelObserver):
58 """Monitor state changes within the Juju Model."""
59 log = None
60 ns_name = None
61 applications = {}
62
63 def __init__(self, ns_name):
64 self.log = logging.getLogger(__name__)
65
66 self.ns_name = ns_name
67
68 def AddApplication(self, application_name, callback, *callback_args):
69 if application_name not in self.applications:
70 self.applications[application_name] = {
71 'callback': callback,
72 'callback_args': callback_args
73 }
74
75 def RemoveApplication(self, application_name):
76 if application_name in self.applications:
77 del self.applications[application_name]
78
79 async def on_change(self, delta, old, new, model):
80 """React to changes in the Juju model."""
81
82 if delta.entity == "unit":
83 # Ignore change events from other applications
84 if delta.data['application'] not in self.applications.keys():
85 return
86
87 try:
88
89 application_name = delta.data['application']
90
91 callback = self.applications[application_name]['callback']
92 callback_args = \
93 self.applications[application_name]['callback_args']
94
95 if old and new:
96 # Fire off a callback with the application state
97 if callback:
98 callback(
99 self.ns_name,
100 delta.data['application'],
101 new.workload_status,
102 new.workload_status_message,
103 *callback_args)
104
105 if old and not new:
106 # This is a charm being removed
107 if callback:
108 callback(
109 self.ns_name,
110 delta.data['application'],
111 "removed",
112 "",
113 *callback_args)
114 except Exception as e:
115 self.log.debug("[1] notify_callback exception: {}".format(e))
116
117 elif delta.entity == "action":
118 # TODO: Decide how we want to notify the user of actions
119
120 # uuid = delta.data['id'] # The Action's unique id
121 # msg = delta.data['message'] # The output of the action
122 #
123 # if delta.data['status'] == "pending":
124 # # The action is queued
125 # pass
126 # elif delta.data['status'] == "completed""
127 # # The action was successful
128 # pass
129 # elif delta.data['status'] == "failed":
130 # # The action failed.
131 # pass
132
133 pass
134
135 ########
136 # TODO
137 #
138 # Create unique models per network service
139 # Document all public functions
140
141
142 class N2VC:
143 def __init__(self,
144 log=None,
145 server='127.0.0.1',
146 port=17070,
147 user='admin',
148 secret=None,
149 artifacts=None,
150 loop=None,
151 ):
152 """Initialize N2VC
153
154 :param vcaconfig dict A dictionary containing the VCA configuration
155
156 :param artifacts str The directory where charms required by a vnfd are
157 stored.
158
159 :Example:
160 n2vc = N2VC(vcaconfig={
161 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm',
162 'user': 'admin',
163 'ip-address': '10.44.127.137',
164 'port': 17070,
165 'artifacts': '/path/to/charms'
166 })
167 """
168
169 # Initialize instance-level variables
170 self.api = None
171 self.log = None
172 self.controller = None
173 self.connecting = False
174 self.authenticated = False
175
176 # For debugging
177 self.refcount = {
178 'controller': 0,
179 'model': 0,
180 }
181
182 self.models = {}
183
184 # Model Observers
185 self.monitors = {}
186
187 # VCA config
188 self.hostname = ""
189 self.port = 17070
190 self.username = ""
191 self.secret = ""
192
193 if log:
194 self.log = log
195 else:
196 self.log = logging.getLogger(__name__)
197
198 # Quiet websocket traffic
199 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
200 logging.getLogger('juju.client.connection').setLevel(logging.WARN)
201 logging.getLogger('model').setLevel(logging.WARN)
202 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
203
204 self.log.debug('JujuApi: instantiated')
205
206 self.server = server
207 self.port = port
208
209 self.secret = secret
210 if user.startswith('user-'):
211 self.user = user
212 else:
213 self.user = 'user-{}'.format(user)
214
215 self.endpoint = '%s:%d' % (server, int(port))
216
217 self.artifacts = artifacts
218
219 self.loop = loop or asyncio.get_event_loop()
220
221 def __del__(self):
222 """Close any open connections."""
223 yield self.logout()
224
225 def notify_callback(self, model_name, application_name, status, message,
226 callback=None, *callback_args):
227 try:
228 if callback:
229 callback(
230 model_name,
231 application_name,
232 status, message,
233 *callback_args,
234 )
235 except Exception as e:
236 self.log.error("[0] notify_callback exception {}".format(e))
237 raise e
238 return True
239
240 # Public methods
241 async def Relate(self, model_name, vnfd):
242 """Create a relation between the charm-enabled VDUs in a VNF.
243
244 The Relation mapping has two parts: the id of the vdu owning the endpoint, and the name of the endpoint.
245
246 vdu:
247 ...
248 relation:
249 - provides: dataVM:db
250 requires: mgmtVM:app
251
252 This tells N2VC that the charm referred to by the dataVM vdu offers a relation named 'db', and the mgmtVM vdu has an 'app' endpoint that should be connected to a database.
253
254 :param str ns_name: The name of the network service.
255 :param dict vnfd: The parsed yaml VNF descriptor.
256 """
257
258 # Currently, the call to Relate() is made automatically after the
259 # deployment of each charm; if the relation depends on a charm that
260 # hasn't been deployed yet, the call will fail silently. This will
261 # prevent an API breakage, with the intent of making this an explicitly
262 # required call in a more object-oriented refactor of the N2VC API.
263
264 configs = []
265 vnf_config = vnfd.get("vnf-configuration")
266 if vnf_config:
267 juju = vnf_config['juju']
268 if juju:
269 configs.append(vnf_config)
270
271 for vdu in vnfd['vdu']:
272 vdu_config = vdu.get('vdu-configuration')
273 if vdu_config:
274 juju = vdu_config['juju']
275 if juju:
276 configs.append(vdu_config)
277
278 def _get_application_name(name):
279 """Get the application name that's mapped to a vnf/vdu."""
280 vnf_member_index = 0
281 vnf_name = vnfd['name']
282
283 for vdu in vnfd.get('vdu'):
284 # Compare the named portion of the relation to the vdu's id
285 if vdu['id'] == name:
286 application_name = self.FormatApplicationName(
287 model_name,
288 vnf_name,
289 str(vnf_member_index),
290 )
291 return application_name
292 else:
293 vnf_member_index += 1
294
295 return None
296
297 # Loop through relations
298 for cfg in configs:
299 if 'juju' in cfg:
300 if 'relation' in juju:
301 for rel in juju['relation']:
302 try:
303
304 # get the application name for the provides
305 (name, endpoint) = rel['provides'].split(':')
306 application_name = _get_application_name(name)
307
308 provides = "{}:{}".format(
309 application_name,
310 endpoint
311 )
312
313 # get the application name for thr requires
314 (name, endpoint) = rel['requires'].split(':')
315 application_name = _get_application_name(name)
316
317 requires = "{}:{}".format(
318 application_name,
319 endpoint
320 )
321 self.log.debug("Relation: {} <-> {}".format(
322 provides,
323 requires
324 ))
325 await self.add_relation(
326 model_name,
327 provides,
328 requires,
329 )
330 except Exception as e:
331 self.log.debug("Exception: {}".format(e))
332
333 return
334
335 async def DeployCharms(self, model_name, application_name, vnfd,
336 charm_path, params={}, machine_spec={},
337 callback=None, *callback_args):
338 """Deploy one or more charms associated with a VNF.
339
340 Deploy the charm(s) referenced in a VNF Descriptor.
341
342 :param str model_name: The name or unique id of the network service.
343 :param str application_name: The name of the application
344 :param dict vnfd: The name of the application
345 :param str charm_path: The path to the Juju charm
346 :param dict params: A dictionary of runtime parameters
347 Examples::
348 {
349 'rw_mgmt_ip': '1.2.3.4',
350 # Pass the initial-config-primitives section of the vnf or vdu
351 'initial-config-primitives': {...}
352 'user_values': dictionary with the day-1 parameters provided at instantiation time. It will replace values
353 inside < >. rw_mgmt_ip will be included here also
354 }
355 :param dict machine_spec: A dictionary describing the machine to
356 install to
357 Examples::
358 {
359 'hostname': '1.2.3.4',
360 'username': 'ubuntu',
361 }
362 :param obj callback: A callback function to receive status changes.
363 :param tuple callback_args: A list of arguments to be passed to the
364 callback
365 """
366
367 ########################################################
368 # Verify the path to the charm exists and is readable. #
369 ########################################################
370 if not os.path.exists(charm_path):
371 self.log.debug("Charm path doesn't exist: {}".format(charm_path))
372 self.notify_callback(
373 model_name,
374 application_name,
375 "failed",
376 callback,
377 *callback_args,
378 )
379 raise JujuCharmNotFound("No artifacts configured.")
380
381 ################################
382 # Login to the Juju controller #
383 ################################
384 if not self.authenticated:
385 self.log.debug("Authenticating with Juju")
386 await self.login()
387
388 ##########################################
389 # Get the model for this network service #
390 ##########################################
391 model = await self.get_model(model_name)
392
393 ########################################
394 # Verify the application doesn't exist #
395 ########################################
396 app = await self.get_application(model, application_name)
397 if app:
398 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name, model_name))
399
400 ################################################################
401 # Register this application with the model-level event monitor #
402 ################################################################
403 if callback:
404 self.monitors[model_name].AddApplication(
405 application_name,
406 callback,
407 *callback_args
408 )
409
410 ########################################################
411 # Check for specific machine placement (native charms) #
412 ########################################################
413 to = ""
414 if machine_spec.keys():
415 if all(k in machine_spec for k in ['host', 'user']):
416 # Enlist an existing machine as a Juju unit
417 machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
418 machine_spec['user'],
419 machine_spec['host'],
420 self.GetPrivateKeyPath(),
421 ))
422 to = machine.id
423
424 #######################################
425 # Get the initial charm configuration #
426 #######################################
427
428 rw_mgmt_ip = None
429 if 'rw_mgmt_ip' in params:
430 rw_mgmt_ip = params['rw_mgmt_ip']
431
432 if 'initial-config-primitive' not in params:
433 params['initial-config-primitive'] = {}
434
435 initial_config = self._get_config_from_dict(
436 params['initial-config-primitive'],
437 {'<rw_mgmt_ip>': rw_mgmt_ip}
438 )
439
440 self.log.debug("JujuApi: Deploying charm ({}/{}) from {}".format(
441 model_name,
442 application_name,
443 charm_path,
444 to=to,
445 ))
446
447 ########################################################
448 # Deploy the charm and apply the initial configuration #
449 ########################################################
450 app = await model.deploy(
451 # We expect charm_path to be either the path to the charm on disk
452 # or in the format of cs:series/name
453 charm_path,
454 # This is the formatted, unique name for this charm
455 application_name=application_name,
456 # Proxy charms should use the current LTS. This will need to be
457 # changed for native charms.
458 series='xenial',
459 # Apply the initial 'config' primitive during deployment
460 config=initial_config,
461 # Where to deploy the charm to.
462 to=to,
463 )
464
465 # Map the vdu id<->app name,
466 #
467 await self.Relate(model_name, vnfd)
468
469 # #######################################
470 # # Execute initial config primitive(s) #
471 # #######################################
472 uuids = await self.ExecuteInitialPrimitives(
473 model_name,
474 application_name,
475 params,
476 )
477 return uuids
478
479 # primitives = {}
480 #
481 # # Build a sequential list of the primitives to execute
482 # for primitive in params['initial-config-primitive']:
483 # try:
484 # if primitive['name'] == 'config':
485 # # This is applied when the Application is deployed
486 # pass
487 # else:
488 # seq = primitive['seq']
489 #
490 # params = {}
491 # if 'parameter' in primitive:
492 # params = primitive['parameter']
493 #
494 # primitives[seq] = {
495 # 'name': primitive['name'],
496 # 'parameters': self._map_primitive_parameters(
497 # params,
498 # {'<rw_mgmt_ip>': rw_mgmt_ip}
499 # ),
500 # }
501 #
502 # for primitive in sorted(primitives):
503 # await self.ExecutePrimitive(
504 # model_name,
505 # application_name,
506 # primitives[primitive]['name'],
507 # callback,
508 # callback_args,
509 # **primitives[primitive]['parameters'],
510 # )
511 # except N2VCPrimitiveExecutionFailed as e:
512 # self.log.debug(
513 # "[N2VC] Exception executing primitive: {}".format(e)
514 # )
515 # raise
516
517 async def GetPrimitiveStatus(self, model_name, uuid):
518 """Get the status of an executed Primitive.
519
520 The status of an executed Primitive will be one of three values:
521 - completed
522 - failed
523 - running
524 """
525 status = None
526 try:
527 if not self.authenticated:
528 await self.login()
529
530 model = await self.get_model(model_name)
531
532 results = await model.get_action_status(uuid)
533
534 if uuid in results:
535 status = results[uuid]
536
537 except Exception as e:
538 self.log.debug(
539 "Caught exception while getting primitive status: {}".format(e)
540 )
541 raise N2VCPrimitiveExecutionFailed(e)
542
543 return status
544
545 async def GetPrimitiveOutput(self, model_name, uuid):
546 """Get the output of an executed Primitive.
547
548 Note: this only returns output for a successfully executed primitive.
549 """
550 results = None
551 try:
552 if not self.authenticated:
553 await self.login()
554
555 model = await self.get_model(model_name)
556 results = await model.get_action_output(uuid, 60)
557 except Exception as e:
558 self.log.debug(
559 "Caught exception while getting primitive status: {}".format(e)
560 )
561 raise N2VCPrimitiveExecutionFailed(e)
562
563 return results
564
565 # async def ProvisionMachine(self, model_name, hostname, username):
566 # """Provision machine for usage with Juju.
567 #
568 # Provisions a previously instantiated machine for use with Juju.
569 # """
570 # try:
571 # if not self.authenticated:
572 # await self.login()
573 #
574 # # FIXME: This is hard-coded until model-per-ns is added
575 # model_name = 'default'
576 #
577 # model = await self.get_model(model_name)
578 # model.add_machine(spec={})
579 #
580 # machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
581 # "ubuntu",
582 # host['address'],
583 # private_key_path,
584 # ))
585 # return machine.id
586 #
587 # except Exception as e:
588 # self.log.debug(
589 # "Caught exception while getting primitive status: {}".format(e)
590 # )
591 # raise N2VCPrimitiveExecutionFailed(e)
592
593 def GetPrivateKeyPath(self):
594 homedir = os.environ['HOME']
595 sshdir = "{}/.ssh".format(homedir)
596 private_key_path = "{}/id_n2vc_rsa".format(sshdir)
597 return private_key_path
598
599 async def GetPublicKey(self):
600 """Get the N2VC SSH public key.abs
601
602 Returns the SSH public key, to be injected into virtual machines to
603 be managed by the VCA.
604
605 The first time this is run, a ssh keypair will be created. The public
606 key is injected into a VM so that we can provision the machine with
607 Juju, after which Juju will communicate with the VM directly via the
608 juju agent.
609 """
610 public_key = ""
611
612 # Find the path to where we expect our key to live.
613 homedir = os.environ['HOME']
614 sshdir = "{}/.ssh".format(homedir)
615 if not os.path.exists(sshdir):
616 os.mkdir(sshdir)
617
618 private_key_path = "{}/id_n2vc_rsa".format(sshdir)
619 public_key_path = "{}.pub".format(private_key_path)
620
621 # If we don't have a key generated, generate it.
622 if not os.path.exists(private_key_path):
623 cmd = "ssh-keygen -t {} -b {} -N '' -f {}".format(
624 "rsa",
625 "4096",
626 private_key_path
627 )
628 subprocess.check_output(shlex.split(cmd))
629
630 # Read the public key
631 with open(public_key_path, "r") as f:
632 public_key = f.readline()
633
634 return public_key
635
636 async def ExecuteInitialPrimitives(self, model_name, application_name,
637 params, callback=None, *callback_args):
638 """Execute multiple primitives.
639
640 Execute multiple primitives as declared in initial-config-primitive.
641 This is useful in cases where the primitives initially failed -- for
642 example, if the charm is a proxy but the proxy hasn't been configured
643 yet.
644 """
645 uuids = []
646 primitives = {}
647
648 # Build a sequential list of the primitives to execute
649 for primitive in params['initial-config-primitive']:
650 try:
651 if primitive['name'] == 'config':
652 pass
653 else:
654 seq = primitive['seq']
655
656 params_ = {}
657 if 'parameter' in primitive:
658 params_ = primitive['parameter']
659
660 user_values = params.get("user_values", {})
661 if 'rw_mgmt_ip' not in user_values:
662 user_values['rw_mgmt_ip'] = None
663 # just for backward compatibility, because it will be provided always by modern version of LCM
664
665 primitives[seq] = {
666 'name': primitive['name'],
667 'parameters': self._map_primitive_parameters(
668 params_,
669 user_values
670 ),
671 }
672
673 for primitive in sorted(primitives):
674 uuids.append(
675 await self.ExecutePrimitive(
676 model_name,
677 application_name,
678 primitives[primitive]['name'],
679 callback,
680 callback_args,
681 **primitives[primitive]['parameters'],
682 )
683 )
684 except N2VCPrimitiveExecutionFailed as e:
685 self.log.debug(
686 "[N2VC] Exception executing primitive: {}".format(e)
687 )
688 raise
689 return uuids
690
691 async def ExecutePrimitive(self, model_name, application_name, primitive,
692 callback, *callback_args, **params):
693 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
694
695 Execute a primitive defined in the VNF descriptor.
696
697 :param str model_name: The name or unique id of the network service.
698 :param str application_name: The name of the application
699 :param str primitive: The name of the primitive to execute.
700 :param obj callback: A callback function to receive status changes.
701 :param tuple callback_args: A list of arguments to be passed to the
702 callback function.
703 :param dict params: A dictionary of key=value pairs representing the
704 primitive's parameters
705 Examples::
706 {
707 'rw_mgmt_ip': '1.2.3.4',
708 # Pass the initial-config-primitives section of the vnf or vdu
709 'initial-config-primitives': {...}
710 }
711 """
712 self.log.debug("Executing primitive={} params={}".format(primitive, params))
713 uuid = None
714 try:
715 if not self.authenticated:
716 await self.login()
717
718 model = await self.get_model(model_name)
719
720 if primitive == 'config':
721 # config is special, and expecting params to be a dictionary
722 await self.set_config(
723 model,
724 application_name,
725 params['params'],
726 )
727 else:
728 app = await self.get_application(model, application_name)
729 if app:
730 # Run against the first (and probably only) unit in the app
731 unit = app.units[0]
732 if unit:
733 action = await unit.run_action(primitive, **params)
734 uuid = action.id
735 except Exception as e:
736 self.log.debug(
737 "Caught exception while executing primitive: {}".format(e)
738 )
739 raise N2VCPrimitiveExecutionFailed(e)
740 return uuid
741
742 async def RemoveCharms(self, model_name, application_name, callback=None,
743 *callback_args):
744 """Remove a charm from the VCA.
745
746 Remove a charm referenced in a VNF Descriptor.
747
748 :param str model_name: The name of the network service.
749 :param str application_name: The name of the application
750 :param obj callback: A callback function to receive status changes.
751 :param tuple callback_args: A list of arguments to be passed to the
752 callback function.
753 """
754 try:
755 if not self.authenticated:
756 await self.login()
757
758 model = await self.get_model(model_name)
759 app = await self.get_application(model, application_name)
760 if app:
761 # Remove this application from event monitoring
762 self.monitors[model_name].RemoveApplication(application_name)
763
764 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
765 self.log.debug(
766 "Removing the application {}".format(application_name)
767 )
768 await app.remove()
769
770 await self.disconnect_model(self.monitors[model_name])
771
772 # Notify the callback that this charm has been removed.
773 self.notify_callback(
774 model_name,
775 application_name,
776 "removed",
777 callback,
778 *callback_args,
779 )
780
781 except Exception as e:
782 print("Caught exception: {}".format(e))
783 self.log.debug(e)
784 raise e
785
786 async def CreateNetworkService(self, ns_uuid):
787 """Create a new Juju model for the Network Service.
788
789 Creates a new Model in the Juju Controller.
790
791 :param str ns_uuid: A unique id representing an instaance of a
792 Network Service.
793
794 :returns: True if the model was created. Raises JujuError on failure.
795 """
796 if not self.authenticated:
797 await self.login()
798
799 models = await self.controller.list_models()
800 if ns_uuid not in models:
801 try:
802 self.models[ns_uuid] = await self.controller.add_model(
803 ns_uuid
804 )
805 except JujuError as e:
806 if "already exists" not in e.message:
807 raise e
808
809 # Create an observer for this model
810 await self.create_model_monitor(ns_uuid)
811
812 return True
813
814 async def DestroyNetworkService(self, ns_uuid):
815 """Destroy a Network Service.
816
817 Destroy the Network Service and any deployed charms.
818
819 :param ns_uuid The unique id of the Network Service
820
821 :returns: True if the model was created. Raises JujuError on failure.
822 """
823
824 # Do not delete the default model. The default model was used by all
825 # Network Services, prior to the implementation of a model per NS.
826 if ns_uuid.lower() is "default":
827 return False
828
829 if not self.authenticated:
830 self.log.debug("Authenticating with Juju")
831 await self.login()
832
833 # Disconnect from the Model
834 if ns_uuid in self.models:
835 await self.disconnect_model(self.models[ns_uuid])
836
837 try:
838 await self.controller.destroy_models(ns_uuid)
839 except JujuError as e:
840 raise NetworkServiceDoesNotExist(
841 "The Network Service '{}' does not exist".format(ns_uuid)
842 )
843
844 return True
845
846 async def GetMetrics(self, model_name, application_name):
847 """Get the metrics collected by the VCA.
848
849 :param model_name The name or unique id of the network service
850 :param application_name The name of the application
851 """
852 metrics = {}
853 model = await self.get_model(model_name)
854 app = await self.get_application(model, application_name)
855 if app:
856 metrics = await app.get_metrics()
857
858 return metrics
859
860 async def HasApplication(self, model_name, application_name):
861 model = await self.get_model(model_name)
862 app = await self.get_application(model, application_name)
863 if app:
864 return True
865 return False
866
867 # Non-public methods
868 async def add_relation(self, model_name, relation1, relation2):
869 """
870 Add a relation between two application endpoints.
871
872 :param str model_name: The name or unique id of the network service
873 :param str relation1: '<application>[:<relation_name>]'
874 :param str relation2: '<application>[:<relation_name>]'
875 """
876
877 if not self.authenticated:
878 await self.login()
879
880 m = await self.get_model(model_name)
881 try:
882 await m.add_relation(relation1, relation2)
883 except JujuAPIError as e:
884 # If one of the applications in the relationship doesn't exist,
885 # or the relation has already been added, let the operation fail
886 # silently.
887 if 'not found' in e.message:
888 return
889 if 'already exists' in e.message:
890 return
891
892 raise e
893
894 # async def apply_config(self, config, application):
895 # """Apply a configuration to the application."""
896 # print("JujuApi: Applying configuration to {}.".format(
897 # application
898 # ))
899 # return await self.set_config(application=application, config=config)
900
901 def _get_config_from_dict(self, config_primitive, values):
902 """Transform the yang config primitive to dict.
903
904 Expected result:
905
906 config = {
907 'config':
908 }
909 """
910 config = {}
911 for primitive in config_primitive:
912 if primitive['name'] == 'config':
913 # config = self._map_primitive_parameters()
914 for parameter in primitive['parameter']:
915 param = str(parameter['name'])
916 if parameter['value'] == "<rw_mgmt_ip>":
917 config[param] = str(values[parameter['value']])
918 else:
919 config[param] = str(parameter['value'])
920
921 return config
922
923 def _map_primitive_parameters(self, parameters, user_values):
924 params = {}
925 for parameter in parameters:
926 param = str(parameter['name'])
927 value = parameter.get('value')
928
929 # map parameters inside a < >; e.g. <rw_mgmt_ip>. with the provided user_values.
930 # Must exist at user_values except if there is a default value
931 if isinstance(value, str) and value.startswith("<") and value.endswith(">"):
932 if parameter['value'][1:-1] in user_values:
933 value = user_values[parameter['value'][1:-1]]
934 elif 'default-value' in parameter:
935 value = parameter['default-value']
936 else:
937 raise KeyError("parameter {}='{}' not supplied ".format(param, value))
938
939 # If there's no value, use the default-value (if set)
940 if value is None and 'default-value' in parameter:
941 value = parameter['default-value']
942
943 # Typecast parameter value, if present
944 paramtype = "string"
945 try:
946 if 'data-type' in parameter:
947 paramtype = str(parameter['data-type']).lower()
948
949 if paramtype == "integer":
950 value = int(value)
951 elif paramtype == "boolean":
952 value = bool(value)
953 else:
954 value = str(value)
955 else:
956 # If there's no data-type, assume the value is a string
957 value = str(value)
958 except ValueError:
959 raise ValueError("parameter {}='{}' cannot be converted to type {}".format(param, value, paramtype))
960
961 params[param] = value
962 return params
963
964 def _get_config_from_yang(self, config_primitive, values):
965 """Transform the yang config primitive to dict."""
966 config = {}
967 for primitive in config_primitive.values():
968 if primitive['name'] == 'config':
969 for parameter in primitive['parameter'].values():
970 param = str(parameter['name'])
971 if parameter['value'] == "<rw_mgmt_ip>":
972 config[param] = str(values[parameter['value']])
973 else:
974 config[param] = str(parameter['value'])
975
976 return config
977
978 def FormatApplicationName(self, *args):
979 """
980 Generate a Juju-compatible Application name
981
982 :param args tuple: Positional arguments to be used to construct the
983 application name.
984
985 Limitations::
986 - Only accepts characters a-z and non-consequitive dashes (-)
987 - Application name should not exceed 50 characters
988
989 Examples::
990
991 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
992 """
993 appname = ""
994 for c in "-".join(list(args)):
995 if c.isdigit():
996 c = chr(97 + int(c))
997 elif not c.isalpha():
998 c = "-"
999 appname += c
1000 return re.sub('-+', '-', appname.lower())
1001
1002 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
1003 # """Format the name of the application
1004 #
1005 # Limitations:
1006 # - Only accepts characters a-z and non-consequitive dashes (-)
1007 # - Application name should not exceed 50 characters
1008 # """
1009 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
1010 # new_name = ''
1011 # for c in name:
1012 # if c.isdigit():
1013 # c = chr(97 + int(c))
1014 # elif not c.isalpha():
1015 # c = "-"
1016 # new_name += c
1017 # return re.sub('\-+', '-', new_name.lower())
1018
1019 def format_model_name(self, name):
1020 """Format the name of model.
1021
1022 Model names may only contain lowercase letters, digits and hyphens
1023 """
1024
1025 return name.replace('_', '-').lower()
1026
1027 async def get_application(self, model, application):
1028 """Get the deployed application."""
1029 if not self.authenticated:
1030 await self.login()
1031
1032 app = None
1033 if application and model:
1034 if model.applications:
1035 if application in model.applications:
1036 app = model.applications[application]
1037
1038 return app
1039
1040 async def get_model(self, model_name):
1041 """Get a model from the Juju Controller.
1042
1043 Note: Model objects returned must call disconnected() before it goes
1044 out of scope."""
1045 if not self.authenticated:
1046 await self.login()
1047
1048 if model_name not in self.models:
1049 # Get the models in the controller
1050 models = await self.controller.list_models()
1051
1052 if model_name not in models:
1053 try:
1054 self.models[model_name] = await self.controller.add_model(
1055 model_name
1056 )
1057 except JujuError as e:
1058 if "already exists" not in e.message:
1059 raise e
1060 else:
1061 self.models[model_name] = await self.controller.get_model(
1062 model_name
1063 )
1064
1065 self.refcount['model'] += 1
1066
1067 # Create an observer for this model
1068 await self.create_model_monitor(model_name)
1069
1070 return self.models[model_name]
1071
1072 async def create_model_monitor(self, model_name):
1073 """Create a monitor for the model, if none exists."""
1074 if not self.authenticated:
1075 await self.login()
1076
1077 if model_name not in self.monitors:
1078 self.monitors[model_name] = VCAMonitor(model_name)
1079 self.models[model_name].add_observer(self.monitors[model_name])
1080
1081 return True
1082
1083 async def login(self):
1084 """Login to the Juju controller."""
1085
1086 if self.authenticated:
1087 return
1088
1089 self.connecting = True
1090
1091 self.log.debug("JujuApi: Logging into controller")
1092
1093 cacert = None
1094 self.controller = Controller(loop=self.loop)
1095
1096 if self.secret:
1097 self.log.debug(
1098 "Connecting to controller... ws://{}:{} as {}/{}".format(
1099 self.endpoint,
1100 self.port,
1101 self.user,
1102 self.secret,
1103 )
1104 )
1105 await self.controller.connect(
1106 endpoint=self.endpoint,
1107 username=self.user,
1108 password=self.secret,
1109 cacert=cacert,
1110 )
1111 self.refcount['controller'] += 1
1112 else:
1113 # current_controller no longer exists
1114 # self.log.debug("Connecting to current controller...")
1115 # await self.controller.connect_current()
1116 # await self.controller.connect(
1117 # endpoint=self.endpoint,
1118 # username=self.user,
1119 # cacert=cacert,
1120 # )
1121 self.log.fatal("VCA credentials not configured.")
1122
1123 self.authenticated = True
1124 self.log.debug("JujuApi: Logged into controller")
1125
1126 async def logout(self):
1127 """Logout of the Juju controller."""
1128 if not self.authenticated:
1129 return False
1130
1131 try:
1132 for model in self.models:
1133 await self.disconnect_model(model)
1134
1135 if self.controller:
1136 self.log.debug("Disconnecting controller {}".format(
1137 self.controller
1138 ))
1139 await self.controller.disconnect()
1140 self.refcount['controller'] -= 1
1141 self.controller = None
1142
1143 self.authenticated = False
1144
1145 self.log.debug(self.refcount)
1146
1147 except Exception as e:
1148 self.log.fatal(
1149 "Fatal error logging out of Juju Controller: {}".format(e)
1150 )
1151 raise e
1152 return True
1153
1154 async def disconnect_model(self, model):
1155 self.log.debug("Disconnecting model {}".format(model))
1156 if model in self.models:
1157 print("Disconnecting model")
1158 await self.models[model].disconnect()
1159 self.refcount['model'] -= 1
1160 self.models[model] = None
1161
1162 # async def remove_application(self, name):
1163 # """Remove the application."""
1164 # if not self.authenticated:
1165 # await self.login()
1166 #
1167 # app = await self.get_application(name)
1168 # if app:
1169 # self.log.debug("JujuApi: Destroying application {}".format(
1170 # name,
1171 # ))
1172 #
1173 # await app.destroy()
1174
1175 async def remove_relation(self, a, b):
1176 """
1177 Remove a relation between two application endpoints
1178
1179 :param a An application endpoint
1180 :param b An application endpoint
1181 """
1182 if not self.authenticated:
1183 await self.login()
1184
1185 m = await self.get_model()
1186 try:
1187 m.remove_relation(a, b)
1188 finally:
1189 await m.disconnect()
1190
1191 async def resolve_error(self, model_name, application=None):
1192 """Resolve units in error state."""
1193 if not self.authenticated:
1194 await self.login()
1195
1196 model = await self.get_model(model_name)
1197
1198 app = await self.get_application(model, application)
1199 if app:
1200 self.log.debug(
1201 "JujuApi: Resolving errors for application {}".format(
1202 application,
1203 )
1204 )
1205
1206 for unit in app.units:
1207 app.resolved(retry=True)
1208
1209 async def run_action(self, model_name, application, action_name, **params):
1210 """Execute an action and return an Action object."""
1211 if not self.authenticated:
1212 await self.login()
1213 result = {
1214 'status': '',
1215 'action': {
1216 'tag': None,
1217 'results': None,
1218 }
1219 }
1220
1221 model = await self.get_model(model_name)
1222
1223 app = await self.get_application(model, application)
1224 if app:
1225 # We currently only have one unit per application
1226 # so use the first unit available.
1227 unit = app.units[0]
1228
1229 self.log.debug(
1230 "JujuApi: Running Action {} against Application {}".format(
1231 action_name,
1232 application,
1233 )
1234 )
1235
1236 action = await unit.run_action(action_name, **params)
1237
1238 # Wait for the action to complete
1239 await action.wait()
1240
1241 result['status'] = action.status
1242 result['action']['tag'] = action.data['id']
1243 result['action']['results'] = action.results
1244
1245 return result
1246
1247 async def set_config(self, model_name, application, config):
1248 """Apply a configuration to the application."""
1249 if not self.authenticated:
1250 await self.login()
1251
1252 app = await self.get_application(model_name, application)
1253 if app:
1254 self.log.debug("JujuApi: Setting config for Application {}".format(
1255 application,
1256 ))
1257 await app.set_config(config)
1258
1259 # Verify the config is set
1260 newconf = await app.get_config()
1261 for key in config:
1262 if config[key] != newconf[key]['value']:
1263 self.log.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key, config[key], newconf[key]))
1264
1265 # async def set_parameter(self, parameter, value, application=None):
1266 # """Set a config parameter for a service."""
1267 # if not self.authenticated:
1268 # await self.login()
1269 #
1270 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1271 # parameter,
1272 # value,
1273 # application,
1274 # ))
1275 # return await self.apply_config(
1276 # {parameter: value},
1277 # application=application,
1278 # )
1279
1280 async def wait_for_application(self, model_name, application_name,
1281 timeout=300):
1282 """Wait for an application to become active."""
1283 if not self.authenticated:
1284 await self.login()
1285
1286 model = await self.get_model(model_name)
1287
1288 app = await self.get_application(model, application_name)
1289 self.log.debug("Application: {}".format(app))
1290 if app:
1291 self.log.debug(
1292 "JujuApi: Waiting {} seconds for Application {}".format(
1293 timeout,
1294 application_name,
1295 )
1296 )
1297
1298 await model.block_until(
1299 lambda: all(
1300 unit.agent_status == 'idle' and unit.workload_status in
1301 ['active', 'unknown'] for unit in app.units
1302 ),
1303 timeout=timeout
1304 )