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