Add workload message to callback
[osm/N2VC.git] / n2vc / vnf.py
1
2 import logging
3 import os
4 import os.path
5 import re
6 import ssl
7 import sys
8 import time
9
10 # FIXME: this should load the juju inside or modules without having to
11 # explicitly install it. Check why it's not working.
12 # Load our subtree of the juju library
13 path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
14 path = os.path.join(path, "modules/libjuju/")
15 if path not in sys.path:
16 sys.path.insert(1, path)
17
18 from juju.controller import Controller
19 from juju.model import Model, ModelObserver
20
21
22 # We might need this to connect to the websocket securely, but test and verify.
23 try:
24 ssl._create_default_https_context = ssl._create_unverified_context
25 except AttributeError:
26 # Legacy Python doesn't verify by default (see pep-0476)
27 # https://www.python.org/dev/peps/pep-0476/
28 pass
29
30
31 # Custom exceptions
32 class JujuCharmNotFound(Exception):
33 """The Charm can't be found or is not readable."""
34
35
36 class JujuApplicationExists(Exception):
37 """The Application already exists."""
38
39
40 class N2VCPrimitiveExecutionFailed(Exception):
41 """Something failed while attempting to execute a primitive."""
42
43
44 # Quiet the debug logging
45 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
46 logging.getLogger('juju.client.connection').setLevel(logging.WARN)
47 logging.getLogger('juju.model').setLevel(logging.WARN)
48 logging.getLogger('juju.machine').setLevel(logging.WARN)
49
50
51 class VCAMonitor(ModelObserver):
52 """Monitor state changes within the Juju Model."""
53 log = None
54 ns_name = None
55 applications = {}
56
57 def __init__(self, ns_name):
58 self.log = logging.getLogger(__name__)
59
60 self.ns_name = ns_name
61
62 def AddApplication(self, application_name, callback, *callback_args):
63 if application_name not in self.applications:
64 self.applications[application_name] = {
65 'callback': callback,
66 'callback_args': callback_args
67 }
68
69 def RemoveApplication(self, application_name):
70 if application_name in self.applications:
71 del self.applications[application_name]
72
73 async def on_change(self, delta, old, new, model):
74 """React to changes in the Juju model."""
75
76 if delta.entity == "unit":
77 # Ignore change events from other applications
78 if delta.data['application'] not in self.applications.keys():
79 return
80
81 try:
82
83 application_name = delta.data['application']
84
85 callback = self.applications[application_name]['callback']
86 callback_args = self.applications[application_name]['callback_args']
87
88 if old and new:
89 old_status = old.workload_status
90 new_status = new.workload_status
91
92 if old_status == new_status:
93 """The workload status may fluctuate around certain events,
94 so wait until the status has stabilized before triggering
95 the callback."""
96 if callback:
97 callback(
98 self.ns_name,
99 delta.data['application'],
100 new_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 elif delta.entity == "action":
116 # TODO: Decide how we want to notify the user of actions
117
118 # uuid = delta.data['id'] # The Action's unique id
119 # msg = delta.data['message'] # The output of the action
120 #
121 # if delta.data['status'] == "pending":
122 # # The action is queued
123 # pass
124 # elif delta.data['status'] == "completed""
125 # # The action was successful
126 # pass
127 # elif delta.data['status'] == "failed":
128 # # The action failed.
129 # pass
130
131 pass
132
133 ########
134 # TODO
135 #
136 # Create unique models per network service
137 # Document all public functions
138
139
140 class N2VC:
141
142 # Juju API
143 api = None
144 log = None
145 controller = None
146 connecting = False
147 authenticated = False
148
149 models = {}
150 default_model = None
151
152 # Model Observers
153 monitors = {}
154
155 # VCA config
156 hostname = ""
157 port = 17070
158 username = ""
159 secret = ""
160
161 def __init__(self,
162 log=None,
163 server='127.0.0.1',
164 port=17070,
165 user='admin',
166 secret=None,
167 artifacts=None
168 ):
169 """Initialize N2VC
170
171 :param vcaconfig dict A dictionary containing the VCA configuration
172
173 :param artifacts str The directory where charms required by a vnfd are
174 stored.
175
176 :Example:
177 n2vc = N2VC(vcaconfig={
178 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm',
179 'user': 'admin',
180 'ip-address': '10.44.127.137',
181 'port': 17070,
182 'artifacts': '/path/to/charms'
183 })
184
185 """
186
187 if log:
188 self.log = log
189 else:
190 self.log = logging.getLogger(__name__)
191
192 # Quiet websocket traffic
193 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
194 logging.getLogger('juju.client.connection').setLevel(logging.WARN)
195 logging.getLogger('model').setLevel(logging.WARN)
196 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
197
198 self.log.debug('JujuApi: instantiated')
199
200 self.server = server
201 self.port = port
202
203 self.secret = secret
204 if user.startswith('user-'):
205 self.user = user
206 else:
207 self.user = 'user-{}'.format(user)
208
209 self.endpoint = '%s:%d' % (server, int(port))
210
211 self.artifacts = artifacts
212
213 def __del__(self):
214 """Close any open connections."""
215 yield self.logout()
216
217 def notify_callback(self, model_name, application_name, status, message, callback=None, *callback_args):
218 try:
219 if callback:
220 callback(model_name, application_name, status, message, *callback_args)
221 except Exception as e:
222 self.log.error("[0] notify_callback exception {}".format(e))
223 raise e
224 return True
225
226 # Public methods
227 async def CreateNetworkService(self, nsd):
228 """Create a new model to encapsulate this network service.
229
230 Create a new model in the Juju controller to encapsulate the
231 charms associated with a network service.
232
233 You can pass either the nsd record or the id of the network
234 service, but this method will fail without one of them.
235 """
236 if not self.authenticated:
237 await self.login()
238
239 # Ideally, we will create a unique model per network service.
240 # This change will require all components, i.e., LCM and SO, to use
241 # N2VC for 100% compatibility. If we adopt unique models for the LCM,
242 # services deployed via LCM would't be manageable via SO and vice versa
243
244 return self.default_model
245
246 async def DeployCharms(self, model_name, application_name, vnfd, charm_path, params={}, machine_spec={}, callback=None, *callback_args):
247 """Deploy one or more charms associated with a VNF.
248
249 Deploy the charm(s) referenced in a VNF Descriptor.
250
251 :param str model_name: The name of the network service.
252 :param str application_name: The name of the application
253 :param dict vnfd: The name of the application
254 :param str charm_path: The path to the Juju charm
255 :param dict params: A dictionary of runtime parameters
256 Examples::
257 {
258 'rw_mgmt_ip': '1.2.3.4',
259 # Pass the initial-config-primitives section of the vnf or vdu
260 'initial-config-primitives': {...}
261 }
262 :param dict machine_spec: A dictionary describing the machine to install to
263 Examples::
264 {
265 'hostname': '1.2.3.4',
266 'username': 'ubuntu',
267 }
268 :param obj callback: A callback function to receive status changes.
269 :param tuple callback_args: A list of arguments to be passed to the callback
270 """
271
272 ########################################################
273 # Verify the path to the charm exists and is readable. #
274 ########################################################
275 if not os.path.exists(charm_path):
276 self.log.debug("Charm path doesn't exist: {}".format(charm_path))
277 self.notify_callback(model_name, application_name, "failed", callback, *callback_args)
278 raise JujuCharmNotFound("No artifacts configured.")
279
280 ################################
281 # Login to the Juju controller #
282 ################################
283 if not self.authenticated:
284 self.log.debug("Authenticating with Juju")
285 await self.login()
286
287 ##########################################
288 # Get the model for this network service #
289 ##########################################
290 # TODO: In a point release, we will use a model per deployed network
291 # service. In the meantime, we will always use the 'default' model.
292 model_name = 'default'
293 model = await self.get_model(model_name)
294
295 ########################################
296 # Verify the application doesn't exist #
297 ########################################
298 app = await self.get_application(model, application_name)
299 if app:
300 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name, model))
301
302 ################################################################
303 # Register this application with the model-level event monitor #
304 ################################################################
305 if callback:
306 self.monitors[model_name].AddApplication(
307 application_name,
308 callback,
309 *callback_args
310 )
311
312 ########################################################
313 # Check for specific machine placement (native charms) #
314 ########################################################
315 to = ""
316 if machine_spec.keys():
317 # TODO: This needs to be tested.
318 # if all(k in machine_spec for k in ['hostname', 'username']):
319 # # Enlist the existing machine in Juju
320 # machine = await self.model.add_machine(spec='ssh:%@%'.format(
321 # specs['host'],
322 # specs['user'],
323 # ))
324 # to = machine.id
325 pass
326
327 #######################################
328 # Get the initial charm configuration #
329 #######################################
330
331 rw_mgmt_ip = None
332 if 'rw_mgmt_ip' in params:
333 rw_mgmt_ip = params['rw_mgmt_ip']
334
335 initial_config = self._get_config_from_dict(
336 params['initial-config-primitive'],
337 {'<rw_mgmt_ip>': rw_mgmt_ip}
338 )
339
340 self.log.debug("JujuApi: Deploying charm ({}) from {}".format(
341 application_name,
342 charm_path,
343 to=to,
344 ))
345
346 ########################################################
347 # Deploy the charm and apply the initial configuration #
348 ########################################################
349 app = await model.deploy(
350 # We expect charm_path to be either the path to the charm on disk
351 # or in the format of cs:series/name
352 charm_path,
353 # This is the formatted, unique name for this charm
354 application_name=application_name,
355 # Proxy charms should use the current LTS. This will need to be
356 # changed for native charms.
357 series='xenial',
358 # Apply the initial 'config' primitive during deployment
359 config=initial_config,
360 # TBD: Where to deploy the charm to.
361 to=None,
362 )
363
364 # #######################################
365 # # Execute initial config primitive(s) #
366 # #######################################
367 primitives = {}
368
369 # Build a sequential list of the primitives to execute
370 for primitive in params['initial-config-primitive']:
371 try:
372 if primitive['name'] == 'config':
373 # This is applied when the Application is deployed
374 pass
375 else:
376 seq = primitive['seq']
377
378 primitives[seq] = {
379 'name': primitive['name'],
380 'parameters': self._map_primitive_parameters(
381 primitive['parameter'],
382 {'<rw_mgmt_ip>': rw_mgmt_ip}
383 ),
384 }
385
386 for primitive in sorted(primitives):
387 await self.ExecutePrimitive(
388 model_name,
389 application_name,
390 primitives[primitive]['name'],
391 callback,
392 callback_args,
393 **primitives[primitive]['parameters'],
394 )
395 except N2VCPrimitiveExecutionFailed as e:
396 self.debug.log(
397 "[N2VC] Exception executing primitive: {}".format(e)
398 )
399 raise
400
401 async def ExecutePrimitive(self, model_name, application_name, primitive, callback, *callback_args, **params):
402 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
403
404 Execute a primitive defined in the VNF descriptor.
405
406 :param str model_name: The name of the network service.
407 :param str application_name: The name of the application
408 :param str primitive: The name of the primitive to execute.
409 :param obj callback: A callback function to receive status changes.
410 :param tuple callback_args: A list of arguments to be passed to the callback function.
411 :param dict params: A dictionary of key=value pairs representing the primitive's parameters
412 Examples::
413 {
414 'rw_mgmt_ip': '1.2.3.4',
415 # Pass the initial-config-primitives section of the vnf or vdu
416 'initial-config-primitives': {...}
417 }
418 """
419 uuid = None
420 try:
421 if not self.authenticated:
422 await self.login()
423
424 # FIXME: This is hard-coded until model-per-ns is added
425 model_name = 'default'
426
427 model = await self.controller.get_model(model_name)
428
429 if primitive == 'config':
430 # config is special, and expecting params to be a dictionary
431 self.log.debug("Setting charm configuration for {}".format(application_name))
432 self.log.debug(params['params'])
433 await self.set_config(model, application_name, params['params'])
434 else:
435 app = await self.get_application(model, application_name)
436 if app:
437 # Run against the first (and probably only) unit in the app
438 unit = app.units[0]
439 if unit:
440 self.log.debug("Executing primitive {}".format(primitive))
441 action = await unit.run_action(primitive, **params)
442 uuid = action.id
443 await model.disconnect()
444 except Exception as e:
445 self.log.debug("Caught exception while executing primitive: {}".format(e))
446 raise e
447 return uuid
448
449 async def RemoveCharms(self, model_name, application_name, callback=None, *callback_args):
450 """Remove a charm from the VCA.
451
452 Remove a charm referenced in a VNF Descriptor.
453
454 :param str model_name: The name of the network service.
455 :param str application_name: The name of the application
456 :param obj callback: A callback function to receive status changes.
457 :param tuple callback_args: A list of arguments to be passed to the callback function.
458 """
459 try:
460 if not self.authenticated:
461 await self.login()
462
463 model = await self.get_model(model_name)
464 app = await self.get_application(model, application_name)
465 if app:
466 # Remove this application from event monitoring
467 self.monitors[model_name].RemoveApplication(application_name)
468
469 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
470 self.log.debug("Removing the application {}".format(application_name))
471 await app.remove()
472
473 # Notify the callback that this charm has been removed.
474 self.notify_callback(model_name, application_name, "removed", callback, *callback_args)
475
476 except Exception as e:
477 print("Caught exception: {}".format(e))
478 self.log.debug(e)
479 raise e
480
481 async def DestroyNetworkService(self, nsd):
482 raise NotImplementedError()
483
484 async def GetMetrics(self, model_name, application_name):
485 """Get the metrics collected by the VCA.
486
487 :param model_name The name of the model
488 :param application_name The name of the application
489 """
490 metrics = {}
491 model = await self.get_model(model_name)
492 app = await self.get_application(model, application_name)
493 if app:
494 metrics = await app.get_metrics()
495
496 return metrics
497
498 # Non-public methods
499 async def add_relation(self, a, b, via=None):
500 """
501 Add a relation between two application endpoints.
502
503 :param a An application endpoint
504 :param b An application endpoint
505 :param via The egress subnet(s) for outbound traffic, e.g.,
506 (192.168.0.0/16,10.0.0.0/8)
507 """
508 if not self.authenticated:
509 await self.login()
510
511 m = await self.get_model()
512 try:
513 m.add_relation(a, b, via)
514 finally:
515 await m.disconnect()
516
517 # async def apply_config(self, config, application):
518 # """Apply a configuration to the application."""
519 # print("JujuApi: Applying configuration to {}.".format(
520 # application
521 # ))
522 # return await self.set_config(application=application, config=config)
523
524 def _get_config_from_dict(self, config_primitive, values):
525 """Transform the yang config primitive to dict.
526
527 Expected result:
528
529 config = {
530 'config':
531 }
532 """
533 config = {}
534 for primitive in config_primitive:
535 if primitive['name'] == 'config':
536 # config = self._map_primitive_parameters()
537 for parameter in primitive['parameter']:
538 param = str(parameter['name'])
539 if parameter['value'] == "<rw_mgmt_ip>":
540 config[param] = str(values[parameter['value']])
541 else:
542 config[param] = str(parameter['value'])
543
544 return config
545
546 def _map_primitive_parameters(self, parameters, values):
547 params = {}
548 for parameter in parameters:
549 param = str(parameter['name'])
550 if parameter['value'] == "<rw_mgmt_ip>":
551 params[param] = str(values[parameter['value']])
552 else:
553 params[param] = str(parameter['value'])
554 return params
555
556 def _get_config_from_yang(self, config_primitive, values):
557 """Transform the yang config primitive to dict."""
558 config = {}
559 for primitive in config_primitive.values():
560 if primitive['name'] == 'config':
561 for parameter in primitive['parameter'].values():
562 param = str(parameter['name'])
563 if parameter['value'] == "<rw_mgmt_ip>":
564 config[param] = str(values[parameter['value']])
565 else:
566 config[param] = str(parameter['value'])
567
568 return config
569
570 def FormatApplicationName(self, *args):
571 """
572 Generate a Juju-compatible Application name
573
574 :param args tuple: Positional arguments to be used to construct the
575 application name.
576
577 Limitations::
578 - Only accepts characters a-z and non-consequitive dashes (-)
579 - Application name should not exceed 50 characters
580
581 Examples::
582
583 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
584 """
585
586 appname = ""
587 for c in "-".join(list(args)):
588 if c.isdigit():
589 c = chr(97 + int(c))
590 elif not c.isalpha():
591 c = "-"
592 appname += c
593 return re.sub('\-+', '-', appname.lower())
594
595
596 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
597 # """Format the name of the application
598 #
599 # Limitations:
600 # - Only accepts characters a-z and non-consequitive dashes (-)
601 # - Application name should not exceed 50 characters
602 # """
603 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
604 # new_name = ''
605 # for c in name:
606 # if c.isdigit():
607 # c = chr(97 + int(c))
608 # elif not c.isalpha():
609 # c = "-"
610 # new_name += c
611 # return re.sub('\-+', '-', new_name.lower())
612
613 def format_model_name(self, name):
614 """Format the name of model.
615
616 Model names may only contain lowercase letters, digits and hyphens
617 """
618
619 return name.replace('_', '-').lower()
620
621 async def get_application(self, model, application):
622 """Get the deployed application."""
623 if not self.authenticated:
624 await self.login()
625
626 app = None
627 if application and model:
628 if model.applications:
629 if application in model.applications:
630 app = model.applications[application]
631
632 return app
633
634 async def get_model(self, model_name='default'):
635 """Get a model from the Juju Controller.
636
637 Note: Model objects returned must call disconnected() before it goes
638 out of scope."""
639 if not self.authenticated:
640 await self.login()
641
642 if model_name not in self.models:
643 print("connecting to model {}".format(model_name))
644 self.models[model_name] = await self.controller.get_model(model_name)
645
646 # Create an observer for this model
647 self.monitors[model_name] = VCAMonitor(model_name)
648 self.models[model_name].add_observer(self.monitors[model_name])
649
650 return self.models[model_name]
651
652 async def login(self):
653 """Login to the Juju controller."""
654
655 if self.authenticated:
656 return
657
658 self.connecting = True
659
660 self.log.debug("JujuApi: Logging into controller")
661
662 cacert = None
663 self.controller = Controller()
664
665 if self.secret:
666 self.log.debug("Connecting to controller... ws://{}:{} as {}/{}".format(self.endpoint, self.port, self.user, self.secret))
667 await self.controller.connect(
668 endpoint=self.endpoint,
669 username=self.user,
670 password=self.secret,
671 cacert=cacert,
672 )
673 else:
674 # current_controller no longer exists
675 # self.log.debug("Connecting to current controller...")
676 # await self.controller.connect_current()
677 # await self.controller.connect(
678 # endpoint=self.endpoint,
679 # username=self.user,
680 # cacert=cacert,
681 # )
682 self.log.fatal("VCA credentials not configured.")
683
684 self.authenticated = True
685 self.log.debug("JujuApi: Logged into controller")
686
687 # self.default_model = await self.controller.get_model("default")
688
689 async def logout(self):
690 """Logout of the Juju controller."""
691 if not self.authenticated:
692 return
693
694 try:
695 if self.default_model:
696 self.log.debug("Disconnecting model {}".format(self.default_model))
697 await self.default_model.disconnect()
698 self.default_model = None
699
700 for model in self.models:
701 await self.models[model].disconnect()
702
703 if self.controller:
704 self.log.debug("Disconnecting controller {}".format(self.controller))
705 await self.controller.disconnect()
706 # self.controller = None
707
708 self.authenticated = False
709 except Exception as e:
710 self.log.fail("Fatal error logging out of Juju Controller: {}".format(e))
711 raise e
712
713
714 # async def remove_application(self, name):
715 # """Remove the application."""
716 # if not self.authenticated:
717 # await self.login()
718 #
719 # app = await self.get_application(name)
720 # if app:
721 # self.log.debug("JujuApi: Destroying application {}".format(
722 # name,
723 # ))
724 #
725 # await app.destroy()
726
727 async def remove_relation(self, a, b):
728 """
729 Remove a relation between two application endpoints
730
731 :param a An application endpoint
732 :param b An application endpoint
733 """
734 if not self.authenticated:
735 await self.login()
736
737 m = await self.get_model()
738 try:
739 m.remove_relation(a, b)
740 finally:
741 await m.disconnect()
742
743 async def resolve_error(self, application=None):
744 """Resolve units in error state."""
745 if not self.authenticated:
746 await self.login()
747
748 app = await self.get_application(self.default_model, application)
749 if app:
750 self.log.debug("JujuApi: Resolving errors for application {}".format(
751 application,
752 ))
753
754 for unit in app.units:
755 app.resolved(retry=True)
756
757 async def run_action(self, application, action_name, **params):
758 """Execute an action and return an Action object."""
759 if not self.authenticated:
760 await self.login()
761 result = {
762 'status': '',
763 'action': {
764 'tag': None,
765 'results': None,
766 }
767 }
768 app = await self.get_application(self.default_model, application)
769 if app:
770 # We currently only have one unit per application
771 # so use the first unit available.
772 unit = app.units[0]
773
774 self.log.debug("JujuApi: Running Action {} against Application {}".format(
775 action_name,
776 application,
777 ))
778
779 action = await unit.run_action(action_name, **params)
780
781 # Wait for the action to complete
782 await action.wait()
783
784 result['status'] = action.status
785 result['action']['tag'] = action.data['id']
786 result['action']['results'] = action.results
787
788 return result
789
790 async def set_config(self, model_name, application, config):
791 """Apply a configuration to the application."""
792 if not self.authenticated:
793 await self.login()
794
795 app = await self.get_application(model_name, application)
796 if app:
797 self.log.debug("JujuApi: Setting config for Application {}".format(
798 application,
799 ))
800 await app.set_config(config)
801
802 # Verify the config is set
803 newconf = await app.get_config()
804 for key in config:
805 if config[key] != newconf[key]['value']:
806 self.log.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key, config[key], newconf[key]))
807
808 # async def set_parameter(self, parameter, value, application=None):
809 # """Set a config parameter for a service."""
810 # if not self.authenticated:
811 # await self.login()
812 #
813 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
814 # parameter,
815 # value,
816 # application,
817 # ))
818 # return await self.apply_config(
819 # {parameter: value},
820 # application=application,
821 # )
822
823 async def wait_for_application(self, name, timeout=300):
824 """Wait for an application to become active."""
825 if not self.authenticated:
826 await self.login()
827
828 app = await self.get_application(self.default_model, name)
829 if app:
830 self.log.debug(
831 "JujuApi: Waiting {} seconds for Application {}".format(
832 timeout,
833 name,
834 )
835 )
836
837 await self.default_model.block_until(
838 lambda: all(
839 unit.agent_status == 'idle'
840 and unit.workload_status
841 in ['active', 'unknown'] for unit in app.units
842 ),
843 timeout=timeout
844 )