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 Model
, 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']
86 callback_args
= self
.applications
[application_name
]['callback_args']
89 old_status
= old
.workload_status
90 new_status
= new
.workload_status
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
99 delta
.data
['application'],
101 new
.workload_status_message
,
105 # This is a charm being removed
109 delta
.data
['application'],
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
118 # uuid = delta.data['id'] # The Action's unique id
119 # msg = delta.data['message'] # The output of the action
121 # if delta.data['status'] == "pending":
122 # # The action is queued
124 # elif delta.data['status'] == "completed""
125 # # The action was successful
127 # elif delta.data['status'] == "failed":
128 # # The action failed.
136 # Create unique models per network service
137 # Document all public functions
147 authenticated
= False
171 :param vcaconfig dict A dictionary containing the VCA configuration
173 :param artifacts str The directory where charms required by a vnfd are
177 n2vc = N2VC(vcaconfig={
178 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm',
180 'ip-address': '10.44.127.137',
182 'artifacts': '/path/to/charms'
190 self
.log
= logging
.getLogger(__name__
)
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)
198 self
.log
.debug('JujuApi: instantiated')
204 if user
.startswith('user-'):
207 self
.user
= 'user-{}'.format(user
)
209 self
.endpoint
= '%s:%d' % (server
, int(port
))
211 self
.artifacts
= artifacts
214 """Close any open connections."""
217 def notify_callback(self
, model_name
, application_name
, status
, message
, callback
=None, *callback_args
):
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
))
227 async def CreateNetworkService(self
, nsd
):
228 """Create a new model to encapsulate this network service.
230 Create a new model in the Juju controller to encapsulate the
231 charms associated with a network service.
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.
236 if not self
.authenticated
:
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
244 return self
.default_model
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.
249 Deploy the charm(s) referenced in a VNF Descriptor.
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
258 'rw_mgmt_ip': '1.2.3.4',
259 # Pass the initial-config-primitives section of the vnf or vdu
260 'initial-config-primitives': {...}
262 :param dict machine_spec: A dictionary describing the machine to install to
265 'hostname': '1.2.3.4',
266 'username': 'ubuntu',
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
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.")
280 ################################
281 # Login to the Juju controller #
282 ################################
283 if not self
.authenticated
:
284 self
.log
.debug("Authenticating with Juju")
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
)
295 ########################################
296 # Verify the application doesn't exist #
297 ########################################
298 app
= await self
.get_application(model
, application_name
)
300 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name
, model_name
))
302 ################################################################
303 # Register this application with the model-level event monitor #
304 ################################################################
306 self
.monitors
[model_name
].AddApplication(
312 ########################################################
313 # Check for specific machine placement (native charms) #
314 ########################################################
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(
327 #######################################
328 # Get the initial charm configuration #
329 #######################################
332 if 'rw_mgmt_ip' in params
:
333 rw_mgmt_ip
= params
['rw_mgmt_ip']
335 # initial_config = {}
336 if 'initial-config-primitive' not in params
:
337 params
['initial-config-primitive'] = {}
339 initial_config
= self
._get
_config
_from
_dict
(
340 params
['initial-config-primitive'],
341 {'<rw_mgmt_ip>': rw_mgmt_ip
}
344 self
.log
.debug("JujuApi: Deploying charm ({}) from {}".format(
350 ########################################################
351 # Deploy the charm and apply the initial configuration #
352 ########################################################
353 app
= await model
.deploy(
354 # We expect charm_path to be either the path to the charm on disk
355 # or in the format of cs:series/name
357 # This is the formatted, unique name for this charm
358 application_name
=application_name
,
359 # Proxy charms should use the current LTS. This will need to be
360 # changed for native charms.
362 # Apply the initial 'config' primitive during deployment
363 config
=initial_config
,
364 # TBD: Where to deploy the charm to.
368 # #######################################
369 # # Execute initial config primitive(s) #
370 # #######################################
373 # Build a sequential list of the primitives to execute
374 for primitive
in params
['initial-config-primitive']:
376 if primitive
['name'] == 'config':
377 # This is applied when the Application is deployed
380 seq
= primitive
['seq']
383 if 'parameter' in primitive
:
384 params
= primitive
['parameter']
387 'name': primitive
['name'],
388 'parameters': self
._map
_primitive
_parameters
(
390 {'<rw_mgmt_ip>': rw_mgmt_ip
}
394 for primitive
in sorted(primitives
):
395 await self
.ExecutePrimitive(
398 primitives
[primitive
]['name'],
401 **primitives
[primitive
]['parameters'],
403 except N2VCPrimitiveExecutionFailed
as e
:
405 "[N2VC] Exception executing primitive: {}".format(e
)
409 async def GetPrimitiveStatus(self
, model_name
, uuid
):
412 if not self
.authenticated
:
415 # FIXME: This is hard-coded until model-per-ns is added
416 model_name
= 'default'
418 model
= await self
.controller
.get_model(model_name
)
420 results
= await model
.get_action_output(uuid
)
422 await model
.disconnect()
423 except Exception as e
:
425 "Caught exception while getting primitive status: {}".format(e
)
427 raise N2VCPrimitiveExecutionFailed(e
)
432 async def ExecutePrimitive(self
, model_name
, application_name
, primitive
, callback
, *callback_args
, **params
):
433 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
435 Execute a primitive defined in the VNF descriptor.
437 :param str model_name: The name of the network service.
438 :param str application_name: The name of the application
439 :param str primitive: The name of the primitive to execute.
440 :param obj callback: A callback function to receive status changes.
441 :param tuple callback_args: A list of arguments to be passed to the callback function.
442 :param dict params: A dictionary of key=value pairs representing the primitive's parameters
445 'rw_mgmt_ip': '1.2.3.4',
446 # Pass the initial-config-primitives section of the vnf or vdu
447 'initial-config-primitives': {...}
452 if not self
.authenticated
:
455 # FIXME: This is hard-coded until model-per-ns is added
456 model_name
= 'default'
458 model
= await self
.controller
.get_model(model_name
)
460 if primitive
== 'config':
461 # config is special, and expecting params to be a dictionary
462 await self
.set_config(
468 app
= await self
.get_application(model
, application_name
)
470 # Run against the first (and probably only) unit in the app
474 "Executing primitive {}".format(primitive
)
476 action
= await unit
.run_action(primitive
, **params
)
478 await model
.disconnect()
479 except Exception as e
:
481 "Caught exception while executing primitive: {}".format(e
)
483 raise N2VCPrimitiveExecutionFailed(e
)
486 async def RemoveCharms(self
, model_name
, application_name
, callback
=None, *callback_args
):
487 """Remove a charm from the VCA.
489 Remove a charm referenced in a VNF Descriptor.
491 :param str model_name: The name of the network service.
492 :param str application_name: The name of the application
493 :param obj callback: A callback function to receive status changes.
494 :param tuple callback_args: A list of arguments to be passed to the callback function.
497 if not self
.authenticated
:
500 model
= await self
.get_model(model_name
)
501 app
= await self
.get_application(model
, application_name
)
503 # Remove this application from event monitoring
504 self
.monitors
[model_name
].RemoveApplication(application_name
)
506 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
507 self
.log
.debug("Removing the application {}".format(application_name
))
510 # Notify the callback that this charm has been removed.
511 self
.notify_callback(model_name
, application_name
, "removed", callback
, *callback_args
)
513 except Exception as e
:
514 print("Caught exception: {}".format(e
))
518 async def DestroyNetworkService(self
, nsd
):
519 raise NotImplementedError()
521 async def GetMetrics(self
, model_name
, application_name
):
522 """Get the metrics collected by the VCA.
524 :param model_name The name of the model
525 :param application_name The name of the application
528 model
= await self
.get_model(model_name
)
529 app
= await self
.get_application(model
, application_name
)
531 metrics
= await app
.get_metrics()
536 async def add_relation(self
, a
, b
, via
=None):
538 Add a relation between two application endpoints.
540 :param a An application endpoint
541 :param b An application endpoint
542 :param via The egress subnet(s) for outbound traffic, e.g.,
543 (192.168.0.0/16,10.0.0.0/8)
545 if not self
.authenticated
:
548 m
= await self
.get_model()
550 m
.add_relation(a
, b
, via
)
554 # async def apply_config(self, config, application):
555 # """Apply a configuration to the application."""
556 # print("JujuApi: Applying configuration to {}.".format(
559 # return await self.set_config(application=application, config=config)
561 def _get_config_from_dict(self
, config_primitive
, values
):
562 """Transform the yang config primitive to dict.
571 for primitive
in config_primitive
:
572 if primitive
['name'] == 'config':
573 # config = self._map_primitive_parameters()
574 for parameter
in primitive
['parameter']:
575 param
= str(parameter
['name'])
576 if parameter
['value'] == "<rw_mgmt_ip>":
577 config
[param
] = str(values
[parameter
['value']])
579 config
[param
] = str(parameter
['value'])
583 def _map_primitive_parameters(self
, parameters
, values
):
585 for parameter
in parameters
:
586 param
= str(parameter
['name'])
587 if parameter
['value'] == "<rw_mgmt_ip>":
588 params
[param
] = str(values
[parameter
['value']])
591 The Juju API uses strictly typed data-types, so we must make
592 sure the parameters from the VNFD match the appropriate type.
594 The honus will still be on the operator, to make sure the
595 data-type in the VNFD matches the one in the charm. N2VC will
596 raise N2VCPrimitiveExecutionFailed when there is a mismatch.
598 There are three data types supported by the YANG model:
603 Each parameter will look like this:
610 'data-type': 'INTEGER',
617 if 'value' in parameter
:
618 # String is the default format
619 val
= str(parameter
['value'])
621 # If the data-type is explicitly set, cast to that type.
622 if 'data-type' in parameter
:
623 dt
= parameter
['data-type'].upper()
627 elif dt
== "BOOLEAN":
628 if val
in ['true', 'false', '0', '1']:
636 def _get_config_from_yang(self
, config_primitive
, values
):
637 """Transform the yang config primitive to dict."""
639 for primitive
in config_primitive
.values():
640 if primitive
['name'] == 'config':
641 for parameter
in primitive
['parameter'].values():
642 param
= str(parameter
['name'])
643 if parameter
['value'] == "<rw_mgmt_ip>":
644 config
[param
] = str(values
[parameter
['value']])
646 config
[param
] = str(parameter
['value'])
650 def FormatApplicationName(self
, *args
):
652 Generate a Juju-compatible Application name
654 :param args tuple: Positional arguments to be used to construct the
658 - Only accepts characters a-z and non-consequitive dashes (-)
659 - Application name should not exceed 50 characters
663 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
667 for c
in "-".join(list(args
)):
670 elif not c
.isalpha():
673 return re
.sub('\-+', '-', appname
.lower())
676 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
677 # """Format the name of the application
680 # - Only accepts characters a-z and non-consequitive dashes (-)
681 # - Application name should not exceed 50 characters
683 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
687 # c = chr(97 + int(c))
688 # elif not c.isalpha():
691 # return re.sub('\-+', '-', new_name.lower())
693 def format_model_name(self
, name
):
694 """Format the name of model.
696 Model names may only contain lowercase letters, digits and hyphens
699 return name
.replace('_', '-').lower()
701 async def get_application(self
, model
, application
):
702 """Get the deployed application."""
703 if not self
.authenticated
:
707 if application
and model
:
708 if model
.applications
:
709 if application
in model
.applications
:
710 app
= model
.applications
[application
]
714 async def get_model(self
, model_name
='default'):
715 """Get a model from the Juju Controller.
717 Note: Model objects returned must call disconnected() before it goes
719 if not self
.authenticated
:
722 if model_name
not in self
.models
:
723 print("connecting to model {}".format(model_name
))
724 self
.models
[model_name
] = await self
.controller
.get_model(model_name
)
726 # Create an observer for this model
727 self
.monitors
[model_name
] = VCAMonitor(model_name
)
728 self
.models
[model_name
].add_observer(self
.monitors
[model_name
])
730 return self
.models
[model_name
]
732 async def login(self
):
733 """Login to the Juju controller."""
735 if self
.authenticated
:
738 self
.connecting
= True
740 self
.log
.debug("JujuApi: Logging into controller")
743 self
.controller
= Controller()
746 self
.log
.debug("Connecting to controller... ws://{}:{} as {}/{}".format(self
.endpoint
, self
.port
, self
.user
, self
.secret
))
747 await self
.controller
.connect(
748 endpoint
=self
.endpoint
,
750 password
=self
.secret
,
754 # current_controller no longer exists
755 # self.log.debug("Connecting to current controller...")
756 # await self.controller.connect_current()
757 # await self.controller.connect(
758 # endpoint=self.endpoint,
759 # username=self.user,
762 self
.log
.fatal("VCA credentials not configured.")
764 self
.authenticated
= True
765 self
.log
.debug("JujuApi: Logged into controller")
767 # self.default_model = await self.controller.get_model("default")
769 async def logout(self
):
770 """Logout of the Juju controller."""
771 if not self
.authenticated
:
775 if self
.default_model
:
776 self
.log
.debug("Disconnecting model {}".format(self
.default_model
))
777 await self
.default_model
.disconnect()
778 self
.default_model
= None
780 for model
in self
.models
:
781 await self
.models
[model
].disconnect()
784 self
.log
.debug("Disconnecting controller {}".format(self
.controller
))
785 await self
.controller
.disconnect()
786 # self.controller = None
788 self
.authenticated
= False
789 except Exception as e
:
790 self
.log
.fail("Fatal error logging out of Juju Controller: {}".format(e
))
794 # async def remove_application(self, name):
795 # """Remove the application."""
796 # if not self.authenticated:
799 # app = await self.get_application(name)
801 # self.log.debug("JujuApi: Destroying application {}".format(
805 # await app.destroy()
807 async def remove_relation(self
, a
, b
):
809 Remove a relation between two application endpoints
811 :param a An application endpoint
812 :param b An application endpoint
814 if not self
.authenticated
:
817 m
= await self
.get_model()
819 m
.remove_relation(a
, b
)
823 async def resolve_error(self
, application
=None):
824 """Resolve units in error state."""
825 if not self
.authenticated
:
828 app
= await self
.get_application(self
.default_model
, application
)
830 self
.log
.debug("JujuApi: Resolving errors for application {}".format(
834 for unit
in app
.units
:
835 app
.resolved(retry
=True)
837 async def run_action(self
, application
, action_name
, **params
):
838 """Execute an action and return an Action object."""
839 if not self
.authenticated
:
848 app
= await self
.get_application(self
.default_model
, application
)
850 # We currently only have one unit per application
851 # so use the first unit available.
854 self
.log
.debug("JujuApi: Running Action {} against Application {}".format(
859 action
= await unit
.run_action(action_name
, **params
)
861 # Wait for the action to complete
864 result
['status'] = action
.status
865 result
['action']['tag'] = action
.data
['id']
866 result
['action']['results'] = action
.results
870 async def set_config(self
, model_name
, application
, config
):
871 """Apply a configuration to the application."""
872 if not self
.authenticated
:
875 app
= await self
.get_application(model_name
, application
)
877 self
.log
.debug("JujuApi: Setting config for Application {}".format(
880 await app
.set_config(config
)
882 # Verify the config is set
883 newconf
= await app
.get_config()
885 if config
[key
] != newconf
[key
]['value']:
886 self
.log
.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key
, config
[key
], newconf
[key
]))
888 # async def set_parameter(self, parameter, value, application=None):
889 # """Set a config parameter for a service."""
890 # if not self.authenticated:
893 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
898 # return await self.apply_config(
899 # {parameter: value},
900 # application=application,
903 async def wait_for_application(self
, name
, timeout
=300):
904 """Wait for an application to become active."""
905 if not self
.authenticated
:
908 app
= await self
.get_application(self
.default_model
, name
)
911 "JujuApi: Waiting {} seconds for Application {}".format(
917 await self
.default_model
.block_until(
919 unit
.agent_status
== 'idle'
920 and unit
.workload_status
921 in ['active', 'unknown'] for unit
in app
.units