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