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