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
)
18 from juju
.controller
import Controller
19 from juju
.model
import ModelObserver
22 # We might need this to connect to the websocket securely, but test and verify.
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/
32 class JujuCharmNotFound(Exception):
33 """The Charm can't be found or is not readable."""
36 class JujuApplicationExists(Exception):
37 """The Application already exists."""
40 class N2VCPrimitiveExecutionFailed(Exception):
41 """Something failed while attempting to execute a primitive."""
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
)
51 class VCAMonitor(ModelObserver
):
52 """Monitor state changes within the Juju Model."""
57 def __init__(self
, ns_name
):
58 self
.log
= logging
.getLogger(__name__
)
60 self
.ns_name
= ns_name
62 def AddApplication(self
, application_name
, callback
, *callback_args
):
63 if application_name
not in self
.applications
:
64 self
.applications
[application_name
] = {
66 'callback_args': callback_args
69 def RemoveApplication(self
, application_name
):
70 if application_name
in self
.applications
:
71 del self
.applications
[application_name
]
73 async def on_change(self
, delta
, old
, new
, model
):
74 """React to changes in the Juju model."""
76 if delta
.entity
== "unit":
77 # Ignore change events from other applications
78 if delta
.data
['application'] not in self
.applications
.keys():
83 application_name
= delta
.data
['application']
85 callback
= self
.applications
[application_name
]['callback']
87 self
.applications
[application_name
]['callback_args']
90 old_status
= old
.workload_status
91 new_status
= new
.workload_status
93 if old_status
== new_status
:
94 """The workload status may fluctuate around certain
95 events, so wait until the status has stabilized before
96 triggering the callback."""
100 delta
.data
['application'],
102 new
.workload_status_message
,
106 # This is a charm being removed
110 delta
.data
['application'],
114 except Exception as e
:
115 self
.log
.debug("[1] notify_callback exception: {}".format(e
))
117 elif delta
.entity
== "action":
118 # TODO: Decide how we want to notify the user of actions
120 # uuid = delta.data['id'] # The Action's unique id
121 # msg = delta.data['message'] # The output of the action
123 # if delta.data['status'] == "pending":
124 # # The action is queued
126 # elif delta.data['status'] == "completed""
127 # # The action was successful
129 # elif delta.data['status'] == "failed":
130 # # The action failed.
138 # Create unique models per network service
139 # Document all public functions
154 :param vcaconfig dict A dictionary containing the VCA configuration
156 :param artifacts str The directory where charms required by a vnfd are
160 n2vc = N2VC(vcaconfig={
161 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm',
163 'ip-address': '10.44.127.137',
165 'artifacts': '/path/to/charms'
169 # Initialize instance-level variables
172 self
.controller
= None
173 self
.connecting
= False
174 self
.authenticated
= False
177 self
.default_model
= None
191 self
.log
= logging
.getLogger(__name__
)
193 # Quiet websocket traffic
194 logging
.getLogger('websockets.protocol').setLevel(logging
.INFO
)
195 logging
.getLogger('juju.client.connection').setLevel(logging
.WARN
)
196 logging
.getLogger('model').setLevel(logging
.WARN
)
197 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
199 self
.log
.debug('JujuApi: instantiated')
205 if user
.startswith('user-'):
208 self
.user
= 'user-{}'.format(user
)
210 self
.endpoint
= '%s:%d' % (server
, int(port
))
212 self
.artifacts
= artifacts
214 self
.loop
= loop
or asyncio
.get_event_loop()
217 """Close any open connections."""
220 def notify_callback(self
, model_name
, application_name
, status
, message
,
221 callback
=None, *callback_args
):
230 except Exception as e
:
231 self
.log
.error("[0] notify_callback exception {}".format(e
))
236 async def CreateNetworkService(self
, nsd
):
237 """Create a new model to encapsulate this network service.
239 Create a new model in the Juju controller to encapsulate the
240 charms associated with a network service.
242 You can pass either the nsd record or the id of the network
243 service, but this method will fail without one of them.
245 if not self
.authenticated
:
248 # Ideally, we will create a unique model per network service.
249 # This change will require all components, i.e., LCM and SO, to use
250 # N2VC for 100% compatibility. If we adopt unique models for the LCM,
251 # services deployed via LCM would't be manageable via SO and vice versa
253 return self
.default_model
255 async def DeployCharms(self
, model_name
, application_name
, vnfd
,
256 charm_path
, params
={}, machine_spec
={},
257 callback
=None, *callback_args
):
258 """Deploy one or more charms associated with a VNF.
260 Deploy the charm(s) referenced in a VNF Descriptor.
262 :param str model_name: The name of the network service.
263 :param str application_name: The name of the application
264 :param dict vnfd: The name of the application
265 :param str charm_path: The path to the Juju charm
266 :param dict params: A dictionary of runtime parameters
269 'rw_mgmt_ip': '1.2.3.4',
270 # Pass the initial-config-primitives section of the vnf or vdu
271 'initial-config-primitives': {...}
273 :param dict machine_spec: A dictionary describing the machine to
277 'hostname': '1.2.3.4',
278 'username': 'ubuntu',
280 :param obj callback: A callback function to receive status changes.
281 :param tuple callback_args: A list of arguments to be passed to the
285 ########################################################
286 # Verify the path to the charm exists and is readable. #
287 ########################################################
288 if not os
.path
.exists(charm_path
):
289 self
.log
.debug("Charm path doesn't exist: {}".format(charm_path
))
290 self
.notify_callback(
297 raise JujuCharmNotFound("No artifacts configured.")
299 ################################
300 # Login to the Juju controller #
301 ################################
302 if not self
.authenticated
:
303 self
.log
.debug("Authenticating with Juju")
306 ##########################################
307 # Get the model for this network service #
308 ##########################################
309 # TODO: In a point release, we will use a model per deployed network
310 # service. In the meantime, we will always use the 'default' model.
311 model_name
= 'default'
312 model
= await self
.get_model(model_name
)
314 ########################################
315 # Verify the application doesn't exist #
316 ########################################
317 app
= await self
.get_application(model
, application_name
)
319 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name
, model_name
))
321 ################################################################
322 # Register this application with the model-level event monitor #
323 ################################################################
325 self
.monitors
[model_name
].AddApplication(
331 ########################################################
332 # Check for specific machine placement (native charms) #
333 ########################################################
335 if machine_spec
.keys():
336 # TODO: This needs to be tested.
337 # if all(k in machine_spec for k in ['hostname', 'username']):
338 # # Enlist the existing machine in Juju
339 # machine = await self.model.add_machine(spec='ssh:%@%'.format(
346 #######################################
347 # Get the initial charm configuration #
348 #######################################
351 if 'rw_mgmt_ip' in params
:
352 rw_mgmt_ip
= params
['rw_mgmt_ip']
354 # initial_config = {}
355 # self.log.debug(type(params))
356 # self.log.debug("Params: {}".format(params))
357 if 'initial-config-primitive' not in params
:
358 params
['initial-config-primitive'] = {}
360 initial_config
= self
._get
_config
_from
_dict
(
361 params
['initial-config-primitive'],
362 {'<rw_mgmt_ip>': rw_mgmt_ip
}
365 self
.log
.debug("JujuApi: Deploying charm ({}) from {}".format(
371 ########################################################
372 # Deploy the charm and apply the initial configuration #
373 ########################################################
374 app
= await model
.deploy(
375 # We expect charm_path to be either the path to the charm on disk
376 # or in the format of cs:series/name
378 # This is the formatted, unique name for this charm
379 application_name
=application_name
,
380 # Proxy charms should use the current LTS. This will need to be
381 # changed for native charms.
383 # Apply the initial 'config' primitive during deployment
384 config
=initial_config
,
385 # TBD: Where to deploy the charm to.
389 # #######################################
390 # # Execute initial config primitive(s) #
391 # #######################################
392 await self
.ExecuteInitialPrimitives(
400 # # Build a sequential list of the primitives to execute
401 # for primitive in params['initial-config-primitive']:
403 # if primitive['name'] == 'config':
404 # # This is applied when the Application is deployed
407 # seq = primitive['seq']
410 # if 'parameter' in primitive:
411 # params = primitive['parameter']
413 # primitives[seq] = {
414 # 'name': primitive['name'],
415 # 'parameters': self._map_primitive_parameters(
417 # {'<rw_mgmt_ip>': rw_mgmt_ip}
421 # for primitive in sorted(primitives):
422 # await self.ExecutePrimitive(
425 # primitives[primitive]['name'],
428 # **primitives[primitive]['parameters'],
430 # except N2VCPrimitiveExecutionFailed as e:
432 # "[N2VC] Exception executing primitive: {}".format(e)
436 async def GetPrimitiveStatus(self
, model_name
, uuid
):
437 """Get the status of an executed Primitive.
439 The status of an executed Primitive will be one of three values:
446 if not self
.authenticated
:
449 # FIXME: This is hard-coded until model-per-ns is added
450 model_name
= 'default'
452 model
= await self
.get_model(model_name
)
454 results
= await model
.get_action_status(uuid
)
457 status
= results
[uuid
]
459 except Exception as e
:
461 "Caught exception while getting primitive status: {}".format(e
)
463 raise N2VCPrimitiveExecutionFailed(e
)
467 async def GetPrimitiveOutput(self
, model_name
, uuid
):
468 """Get the output of an executed Primitive.
470 Note: this only returns output for a successfully executed primitive.
474 if not self
.authenticated
:
477 # FIXME: This is hard-coded until model-per-ns is added
478 model_name
= 'default'
480 model
= await self
.get_model(model_name
)
481 results
= await model
.get_action_output(uuid
, 60)
482 except Exception as e
:
484 "Caught exception while getting primitive status: {}".format(e
)
486 raise N2VCPrimitiveExecutionFailed(e
)
490 async def ExecuteInitialPrimitives(self
, model_name
, application_name
,
491 params
, callback
=None, *callback_args
):
492 """Execute multiple primitives.
494 Execute multiple primitives as declared in initial-config-primitive.
495 This is useful in cases where the primitives initially failed -- for
496 example, if the charm is a proxy but the proxy hasn't been configured
502 # Build a sequential list of the primitives to execute
503 for primitive
in params
['initial-config-primitive']:
505 if primitive
['name'] == 'config':
508 seq
= primitive
['seq']
511 if 'parameter' in primitive
:
512 params
= primitive
['parameter']
515 'name': primitive
['name'],
516 'parameters': self
._map
_primitive
_parameters
(
518 {'<rw_mgmt_ip>': None}
522 for primitive
in sorted(primitives
):
524 await self
.ExecutePrimitive(
527 primitives
[primitive
]['name'],
530 **primitives
[primitive
]['parameters'],
533 except N2VCPrimitiveExecutionFailed
as e
:
535 "[N2VC] Exception executing primitive: {}".format(e
)
540 async def ExecutePrimitive(self
, model_name
, application_name
, primitive
,
541 callback
, *callback_args
, **params
):
542 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
544 Execute a primitive defined in the VNF descriptor.
546 :param str model_name: The name of the network service.
547 :param str application_name: The name of the application
548 :param str primitive: The name of the primitive to execute.
549 :param obj callback: A callback function to receive status changes.
550 :param tuple callback_args: A list of arguments to be passed to the
552 :param dict params: A dictionary of key=value pairs representing the
553 primitive's parameters
556 'rw_mgmt_ip': '1.2.3.4',
557 # Pass the initial-config-primitives section of the vnf or vdu
558 'initial-config-primitives': {...}
561 self
.log
.debug("Executing {}".format(primitive
))
564 if not self
.authenticated
:
567 # FIXME: This is hard-coded until model-per-ns is added
568 model_name
= 'default'
570 model
= await self
.get_model(model_name
)
572 if primitive
== 'config':
573 # config is special, and expecting params to be a dictionary
574 await self
.set_config(
580 app
= await self
.get_application(model
, application_name
)
582 # Run against the first (and probably only) unit in the app
585 action
= await unit
.run_action(primitive
, **params
)
587 except Exception as e
:
589 "Caught exception while executing primitive: {}".format(e
)
591 raise N2VCPrimitiveExecutionFailed(e
)
594 async def RemoveCharms(self
, model_name
, application_name
, callback
=None,
596 """Remove a charm from the VCA.
598 Remove a charm referenced in a VNF Descriptor.
600 :param str model_name: The name of the network service.
601 :param str application_name: The name of the application
602 :param obj callback: A callback function to receive status changes.
603 :param tuple callback_args: A list of arguments to be passed to the
607 if not self
.authenticated
:
610 model
= await self
.get_model(model_name
)
611 app
= await self
.get_application(model
, application_name
)
613 # Remove this application from event monitoring
614 self
.monitors
[model_name
].RemoveApplication(application_name
)
616 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
618 "Removing the application {}".format(application_name
)
622 # Notify the callback that this charm has been removed.
623 self
.notify_callback(
631 except Exception as e
:
632 print("Caught exception: {}".format(e
))
636 async def DestroyNetworkService(self
, nsd
):
637 raise NotImplementedError()
639 async def GetMetrics(self
, model_name
, application_name
):
640 """Get the metrics collected by the VCA.
642 :param model_name The name of the model
643 :param application_name The name of the application
646 model
= await self
.get_model(model_name
)
647 app
= await self
.get_application(model
, application_name
)
649 metrics
= await app
.get_metrics()
654 async def add_relation(self
, a
, b
, via
=None):
656 Add a relation between two application endpoints.
658 :param a An application endpoint
659 :param b An application endpoint
660 :param via The egress subnet(s) for outbound traffic, e.g.,
661 (192.168.0.0/16,10.0.0.0/8)
663 if not self
.authenticated
:
666 m
= await self
.get_model()
668 m
.add_relation(a
, b
, via
)
672 # async def apply_config(self, config, application):
673 # """Apply a configuration to the application."""
674 # print("JujuApi: Applying configuration to {}.".format(
677 # return await self.set_config(application=application, config=config)
679 def _get_config_from_dict(self
, config_primitive
, values
):
680 """Transform the yang config primitive to dict.
689 for primitive
in config_primitive
:
690 if primitive
['name'] == 'config':
691 # config = self._map_primitive_parameters()
692 for parameter
in primitive
['parameter']:
693 param
= str(parameter
['name'])
694 if parameter
['value'] == "<rw_mgmt_ip>":
695 config
[param
] = str(values
[parameter
['value']])
697 config
[param
] = str(parameter
['value'])
701 def _map_primitive_parameters(self
, parameters
, values
):
703 for parameter
in parameters
:
704 param
= str(parameter
['name'])
706 # Typecast parameter value, if present
707 if 'data-type' in parameter
:
708 paramtype
= str(parameter
['data-type']).lower()
711 if paramtype
== "integer":
712 value
= int(parameter
['value'])
713 elif paramtype
== "boolean":
714 value
= bool(parameter
['value'])
716 value
= str(parameter
['value'])
718 if parameter
['value'] == "<rw_mgmt_ip>":
719 params
[param
] = str(values
[parameter
['value']])
721 params
[param
] = value
724 def _get_config_from_yang(self
, config_primitive
, values
):
725 """Transform the yang config primitive to dict."""
727 for primitive
in config_primitive
.values():
728 if primitive
['name'] == 'config':
729 for parameter
in primitive
['parameter'].values():
730 param
= str(parameter
['name'])
731 if parameter
['value'] == "<rw_mgmt_ip>":
732 config
[param
] = str(values
[parameter
['value']])
734 config
[param
] = str(parameter
['value'])
739 def FormatApplicationName(self
, *args
):
741 Generate a Juju-compatible Application name
743 :param args tuple: Positional arguments to be used to construct the
747 - Only accepts characters a-z and non-consequitive dashes (-)
748 - Application name should not exceed 50 characters
752 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
756 for c
in "-".join(list(args
)):
759 elif not c
.isalpha():
762 return re
.sub('\-+', '-', appname
.lower())
764 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
765 # """Format the name of the application
768 # - Only accepts characters a-z and non-consequitive dashes (-)
769 # - Application name should not exceed 50 characters
771 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
775 # c = chr(97 + int(c))
776 # elif not c.isalpha():
779 # return re.sub('\-+', '-', new_name.lower())
781 def format_model_name(self
, name
):
782 """Format the name of model.
784 Model names may only contain lowercase letters, digits and hyphens
787 return name
.replace('_', '-').lower()
789 async def get_application(self
, model
, application
):
790 """Get the deployed application."""
791 if not self
.authenticated
:
795 if application
and model
:
796 if model
.applications
:
797 if application
in model
.applications
:
798 app
= model
.applications
[application
]
802 async def get_model(self
, model_name
='default'):
803 """Get a model from the Juju Controller.
805 Note: Model objects returned must call disconnected() before it goes
807 if not self
.authenticated
:
810 if model_name
not in self
.models
:
811 self
.models
[model_name
] = await self
.controller
.get_model(
815 # Create an observer for this model
816 self
.monitors
[model_name
] = VCAMonitor(model_name
)
817 self
.models
[model_name
].add_observer(self
.monitors
[model_name
])
819 return self
.models
[model_name
]
821 async def login(self
):
822 """Login to the Juju controller."""
824 if self
.authenticated
:
827 self
.connecting
= True
829 self
.log
.debug("JujuApi: Logging into controller")
832 self
.controller
= Controller(loop
=self
.loop
)
836 "Connecting to controller... ws://{}:{} as {}/{}".format(
843 await self
.controller
.connect(
844 endpoint
=self
.endpoint
,
846 password
=self
.secret
,
850 # current_controller no longer exists
851 # self.log.debug("Connecting to current controller...")
852 # await self.controller.connect_current()
853 # await self.controller.connect(
854 # endpoint=self.endpoint,
855 # username=self.user,
858 self
.log
.fatal("VCA credentials not configured.")
860 self
.authenticated
= True
861 self
.log
.debug("JujuApi: Logged into controller")
863 # self.default_model = await self.controller.get_model("default")
865 async def logout(self
):
866 """Logout of the Juju controller."""
867 if not self
.authenticated
:
871 if self
.default_model
:
872 self
.log
.debug("Disconnecting model {}".format(
875 await self
.default_model
.disconnect()
876 self
.default_model
= None
878 for model
in self
.models
:
879 await self
.models
[model
].disconnect()
883 self
.log
.debug("Disconnecting controller {}".format(
886 await self
.controller
.disconnect()
887 self
.controller
= None
889 self
.authenticated
= False
890 except Exception as e
:
892 "Fatal error logging out of Juju Controller: {}".format(e
)
896 # async def remove_application(self, name):
897 # """Remove the application."""
898 # if not self.authenticated:
901 # app = await self.get_application(name)
903 # self.log.debug("JujuApi: Destroying application {}".format(
907 # await app.destroy()
909 async def remove_relation(self
, a
, b
):
911 Remove a relation between two application endpoints
913 :param a An application endpoint
914 :param b An application endpoint
916 if not self
.authenticated
:
919 m
= await self
.get_model()
921 m
.remove_relation(a
, b
)
925 async def resolve_error(self
, application
=None):
926 """Resolve units in error state."""
927 if not self
.authenticated
:
930 app
= await self
.get_application(self
.default_model
, application
)
933 "JujuApi: Resolving errors for application {}".format(
938 for unit
in app
.units
:
939 app
.resolved(retry
=True)
941 async def run_action(self
, application
, action_name
, **params
):
942 """Execute an action and return an Action object."""
943 if not self
.authenticated
:
952 app
= await self
.get_application(self
.default_model
, application
)
954 # We currently only have one unit per application
955 # so use the first unit available.
959 "JujuApi: Running Action {} against Application {}".format(
965 action
= await unit
.run_action(action_name
, **params
)
967 # Wait for the action to complete
970 result
['status'] = action
.status
971 result
['action']['tag'] = action
.data
['id']
972 result
['action']['results'] = action
.results
976 async def set_config(self
, model_name
, application
, config
):
977 """Apply a configuration to the application."""
978 if not self
.authenticated
:
981 app
= await self
.get_application(model_name
, application
)
983 self
.log
.debug("JujuApi: Setting config for Application {}".format(
986 await app
.set_config(config
)
988 # Verify the config is set
989 newconf
= await app
.get_config()
991 if config
[key
] != newconf
[key
]['value']:
992 self
.log
.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key
, config
[key
], newconf
[key
]))
994 # async def set_parameter(self, parameter, value, application=None):
995 # """Set a config parameter for a service."""
996 # if not self.authenticated:
999 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1004 # return await self.apply_config(
1005 # {parameter: value},
1006 # application=application,
1009 async def wait_for_application(self
, model_name
, application_name
,
1011 """Wait for an application to become active."""
1012 if not self
.authenticated
:
1015 # TODO: In a point release, we will use a model per deployed network
1016 # service. In the meantime, we will always use the 'default' model.
1017 model_name
= 'default'
1018 model
= await self
.get_model(model_name
)
1020 app
= await self
.get_application(model
, application_name
)
1021 self
.log
.debug("Application: {}".format(app
))
1022 # app = await self.get_application(model_name, application_name)
1025 "JujuApi: Waiting {} seconds for Application {}".format(
1031 await model
.block_until(
1033 unit
.agent_status
== 'idle' and unit
.workload_status
in
1034 ['active', 'unknown'] for unit
in app
.units