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'],
104 # This is a charm being removed
108 delta
.data
['application'],
111 except Exception as e
:
112 self
.log
.debug("[1] notify_callback exception {}".format(e
))
113 elif delta
.entity
== "action":
114 # TODO: Decide how we want to notify the user of actions
116 # uuid = delta.data['id'] # The Action's unique id
117 # msg = delta.data['message'] # The output of the action
119 # if delta.data['status'] == "pending":
120 # # The action is queued
122 # elif delta.data['status'] == "completed""
123 # # The action was successful
125 # elif delta.data['status'] == "failed":
126 # # The action failed.
134 # Create unique models per network service
135 # Document all public functions
145 authenticated
= False
169 :param vcaconfig dict A dictionary containing the VCA configuration
171 :param artifacts str The directory where charms required by a vnfd are
175 n2vc = N2VC(vcaconfig={
176 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm',
178 'ip-address': '10.44.127.137',
180 'artifacts': '/path/to/charms'
188 self
.log
= logging
.getLogger(__name__
)
190 # Quiet websocket traffic
191 logging
.getLogger('websockets.protocol').setLevel(logging
.INFO
)
192 logging
.getLogger('juju.client.connection').setLevel(logging
.WARN
)
193 logging
.getLogger('model').setLevel(logging
.WARN
)
194 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
196 self
.log
.debug('JujuApi: instantiated')
202 if user
.startswith('user-'):
205 self
.user
= 'user-{}'.format(user
)
207 self
.endpoint
= '%s:%d' % (server
, int(port
))
209 self
.artifacts
= artifacts
212 """Close any open connections."""
215 def notify_callback(self
, model_name
, application_name
, status
, callback
=None, *callback_args
):
218 callback(model_name
, application_name
, status
, *callback_args
)
219 except Exception as e
:
220 self
.log
.error("[0] notify_callback exception {}".format(e
))
225 async def CreateNetworkService(self
, nsd
):
226 """Create a new model to encapsulate this network service.
228 Create a new model in the Juju controller to encapsulate the
229 charms associated with a network service.
231 You can pass either the nsd record or the id of the network
232 service, but this method will fail without one of them.
234 if not self
.authenticated
:
237 # Ideally, we will create a unique model per network service.
238 # This change will require all components, i.e., LCM and SO, to use
239 # N2VC for 100% compatibility. If we adopt unique models for the LCM,
240 # services deployed via LCM would't be manageable via SO and vice versa
242 return self
.default_model
244 async def DeployCharms(self
, model_name
, application_name
, vnfd
, charm_path
, params
={}, machine_spec
={}, callback
=None, *callback_args
):
245 """Deploy one or more charms associated with a VNF.
247 Deploy the charm(s) referenced in a VNF Descriptor.
249 You can pass either the nsd record or the id of the network
250 service, but this method will fail without one of them.
252 :param str ns_name: The name of the network service
253 :param str application_name: The name of the application
254 :param dict vnfd: The name of the application
255 :param str charm_path: The path to the Juju charm
256 :param dict params: A dictionary of runtime parameters
259 'rw_mgmt_ip': '1.2.3.4',
260 # Pass the initial-config-primitives section of the vnf or vdu
261 'initial-config-primitives': {...}
263 :param dict machine_spec: A dictionary describing the machine to install to
266 'hostname': '1.2.3.4',
267 'username': 'ubuntu',
269 :param obj callback: A callback function to receive status changes.
270 :param tuple callback_args: A list of arguments to be passed to the callback
273 ########################################################
274 # Verify the path to the charm exists and is readable. #
275 ########################################################
276 if not os
.path
.exists(charm_path
):
277 self
.log
.debug("Charm path doesn't exist: {}".format(charm_path
))
278 self
.notify_callback(model_name
, application_name
, "failed", callback
, *callback_args
)
279 raise JujuCharmNotFound("No artifacts configured.")
281 ################################
282 # Login to the Juju controller #
283 ################################
284 if not self
.authenticated
:
285 self
.log
.debug("Authenticating with Juju")
288 ##########################################
289 # Get the model for this network service #
290 ##########################################
291 # TODO: In a point release, we will use a model per deployed network
292 # service. In the meantime, we will always use the 'default' model.
293 model_name
= 'default'
294 model
= await self
.get_model(model_name
)
296 ########################################
297 # Verify the application doesn't exist #
298 ########################################
299 app
= await self
.get_application(model
, application_name
)
301 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name
, model
))
303 ################################################################
304 # Register this application with the model-level event monitor #
305 ################################################################
307 self
.monitors
[model_name
].AddApplication(
313 ########################################################
314 # Check for specific machine placement (native charms) #
315 ########################################################
317 if machine_spec
.keys():
318 # TODO: This needs to be tested.
319 # if all(k in machine_spec for k in ['hostname', 'username']):
320 # # Enlist the existing machine in Juju
321 # machine = await self.model.add_machine(spec='ssh:%@%'.format(
328 #######################################
329 # Get the initial charm configuration #
330 #######################################
333 if 'rw_mgmt_ip' in params
:
334 rw_mgmt_ip
= params
['rw_mgmt_ip']
336 initial_config
= self
._get
_config
_from
_dict
(
337 params
['initial-config-primitive'],
338 {'<rw_mgmt_ip>': rw_mgmt_ip
}
341 self
.log
.debug("JujuApi: Deploying charm ({}) from {}".format(
347 ########################################################
348 # Deploy the charm and apply the initial configuration #
349 ########################################################
350 app
= await model
.deploy(
351 # We expect charm_path to be either the path to the charm on disk
352 # or in the format of cs:series/name
354 # This is the formatted, unique name for this charm
355 application_name
=application_name
,
356 # Proxy charms should use the current LTS. This will need to be
357 # changed for native charms.
359 # Apply the initial 'config' primitive during deployment
360 config
=initial_config
,
361 # TBD: Where to deploy the charm to.
365 # #######################################
366 # # Execute initial config primitive(s) #
367 # #######################################
370 # Build a sequential list of the primitives to execute
371 for primitive
in params
['initial-config-primitive']:
373 if primitive
['name'] == 'config':
374 # This is applied when the Application is deployed
377 seq
= primitive
['seq']
380 'name': primitive
['name'],
381 'parameters': self
._map
_primitive
_parameters
(
382 primitive
['parameter'],
383 {'<rw_mgmt_ip>': rw_mgmt_ip
}
387 for primitive
in sorted(primitives
):
388 await self
.ExecutePrimitive(
391 primitives
[primitive
]['name'],
394 **primitives
[primitive
]['parameters'],
396 except N2VCPrimitiveExecutionFailed
as e
:
398 "[N2VC] Exception executing primitive: {}".format(e
)
402 async def ExecutePrimitive(self
, model_name
, application_name
, primitive
, callback
, *callback_args
, **params
):
404 if not self
.authenticated
:
407 # FIXME: This is hard-coded until model-per-ns is added
408 model_name
= 'default'
410 model
= await self
.controller
.get_model(model_name
)
412 if primitive
== 'config':
413 # config is special, and expecting params to be a dictionary
414 self
.log
.debug("Setting charm configuration for {}".format(application_name
))
415 self
.log
.debug(params
['params'])
416 await self
.set_config(model
, application_name
, params
['params'])
418 app
= await self
.get_application(model
, application_name
)
420 # Run against the first (and probably only) unit in the app
423 self
.log
.debug("Executing primitive {}".format(primitive
))
424 action
= await unit
.run_action(primitive
, **params
)
425 # action = await action.wait()
426 await model
.disconnect()
427 except Exception as e
:
428 self
.log
.debug("Caught exception while executing primitive: {}".format(e
))
431 async def RemoveCharms(self
, model_name
, application_name
, callback
=None, *callback_args
):
433 if not self
.authenticated
:
436 model
= await self
.get_model(model_name
)
437 app
= await self
.get_application(model
, application_name
)
439 # Remove this application from event monitoring
440 self
.monitors
[model_name
].RemoveApplication(application_name
)
442 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
443 self
.log
.debug("Removing the application {}".format(application_name
))
446 # Notify the callback that this charm has been removed.
447 self
.notify_callback(model_name
, application_name
, "removed", callback
, *callback_args
)
449 except Exception as e
:
450 print("Caught exception: {}".format(e
))
454 async def DestroyNetworkService(self
, nsd
):
455 raise NotImplementedError()
457 async def GetMetrics(self
, model_name
, application_name
):
458 """Get the metrics collected by the VCA.
460 :param model_name The name of the model
461 :param application_name The name of the application
464 model
= await self
.get_model(model_name
)
465 app
= await self
.get_application(model
, application_name
)
467 metrics
= await app
.get_metrics()
472 async def add_relation(self
, a
, b
, via
=None):
474 Add a relation between two application endpoints.
476 :param a An application endpoint
477 :param b An application endpoint
478 :param via The egress subnet(s) for outbound traffic, e.g.,
479 (192.168.0.0/16,10.0.0.0/8)
481 if not self
.authenticated
:
484 m
= await self
.get_model()
486 m
.add_relation(a
, b
, via
)
490 # async def apply_config(self, config, application):
491 # """Apply a configuration to the application."""
492 # print("JujuApi: Applying configuration to {}.".format(
495 # return await self.set_config(application=application, config=config)
497 def _get_config_from_dict(self
, config_primitive
, values
):
498 """Transform the yang config primitive to dict.
507 for primitive
in config_primitive
:
508 if primitive
['name'] == 'config':
509 # config = self._map_primitive_parameters()
510 for parameter
in primitive
['parameter']:
511 param
= str(parameter
['name'])
512 if parameter
['value'] == "<rw_mgmt_ip>":
513 config
[param
] = str(values
[parameter
['value']])
515 config
[param
] = str(parameter
['value'])
519 def _map_primitive_parameters(self
, parameters
, values
):
521 for parameter
in parameters
:
522 param
= str(parameter
['name'])
523 if parameter
['value'] == "<rw_mgmt_ip>":
524 params
[param
] = str(values
[parameter
['value']])
526 params
[param
] = str(parameter
['value'])
529 def _get_config_from_yang(self
, config_primitive
, values
):
530 """Transform the yang config primitive to dict."""
532 for primitive
in config_primitive
.values():
533 if primitive
['name'] == 'config':
534 for parameter
in primitive
['parameter'].values():
535 param
= str(parameter
['name'])
536 if parameter
['value'] == "<rw_mgmt_ip>":
537 config
[param
] = str(values
[parameter
['value']])
539 config
[param
] = str(parameter
['value'])
543 def FormatApplicationName(self
, *args
):
545 Generate a Juju-compatible Application name
547 :param args tuple: Positional arguments to be used to construct the
551 - Only accepts characters a-z and non-consequitive dashes (-)
552 - Application name should not exceed 50 characters
556 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
560 for c
in "-".join(list(args
)):
563 elif not c
.isalpha():
566 return re
.sub('\-+', '-', appname
.lower())
569 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
570 # """Format the name of the application
573 # - Only accepts characters a-z and non-consequitive dashes (-)
574 # - Application name should not exceed 50 characters
576 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
580 # c = chr(97 + int(c))
581 # elif not c.isalpha():
584 # return re.sub('\-+', '-', new_name.lower())
586 def format_model_name(self
, name
):
587 """Format the name of model.
589 Model names may only contain lowercase letters, digits and hyphens
592 return name
.replace('_', '-').lower()
594 async def get_application(self
, model
, application
):
595 """Get the deployed application."""
596 if not self
.authenticated
:
600 if application
and model
:
601 if model
.applications
:
602 if application
in model
.applications
:
603 app
= model
.applications
[application
]
607 async def get_model(self
, model_name
='default'):
608 """Get a model from the Juju Controller.
610 Note: Model objects returned must call disconnected() before it goes
612 if not self
.authenticated
:
615 if model_name
not in self
.models
:
616 print("connecting to model {}".format(model_name
))
617 self
.models
[model_name
] = await self
.controller
.get_model(model_name
)
619 # Create an observer for this model
620 self
.monitors
[model_name
] = VCAMonitor(model_name
)
621 self
.models
[model_name
].add_observer(self
.monitors
[model_name
])
623 return self
.models
[model_name
]
625 async def login(self
):
626 """Login to the Juju controller."""
628 if self
.authenticated
:
631 self
.connecting
= True
633 self
.log
.debug("JujuApi: Logging into controller")
636 self
.controller
= Controller()
639 self
.log
.debug("Connecting to controller... ws://{}:{} as {}/{}".format(self
.endpoint
, self
.port
, self
.user
, self
.secret
))
640 await self
.controller
.connect(
641 endpoint
=self
.endpoint
,
643 password
=self
.secret
,
647 # current_controller no longer exists
648 # self.log.debug("Connecting to current controller...")
649 # await self.controller.connect_current()
650 # await self.controller.connect(
651 # endpoint=self.endpoint,
652 # username=self.user,
655 self
.log
.fatal("VCA credentials not configured.")
657 self
.authenticated
= True
658 self
.log
.debug("JujuApi: Logged into controller")
660 # self.default_model = await self.controller.get_model("default")
662 async def logout(self
):
663 """Logout of the Juju controller."""
664 if not self
.authenticated
:
668 if self
.default_model
:
669 self
.log
.debug("Disconnecting model {}".format(self
.default_model
))
670 await self
.default_model
.disconnect()
671 self
.default_model
= None
673 for model
in self
.models
:
674 await self
.models
[model
].disconnect()
677 self
.log
.debug("Disconnecting controller {}".format(self
.controller
))
678 await self
.controller
.disconnect()
679 # self.controller = None
681 self
.authenticated
= False
682 except Exception as e
:
683 self
.log
.fail("Fatal error logging out of Juju Controller: {}".format(e
))
687 # async def remove_application(self, name):
688 # """Remove the application."""
689 # if not self.authenticated:
692 # app = await self.get_application(name)
694 # self.log.debug("JujuApi: Destroying application {}".format(
698 # await app.destroy()
700 async def remove_relation(self
, a
, b
):
702 Remove a relation between two application endpoints
704 :param a An application endpoint
705 :param b An application endpoint
707 if not self
.authenticated
:
710 m
= await self
.get_model()
712 m
.remove_relation(a
, b
)
716 async def resolve_error(self
, application
=None):
717 """Resolve units in error state."""
718 if not self
.authenticated
:
721 app
= await self
.get_application(self
.default_model
, application
)
723 self
.log
.debug("JujuApi: Resolving errors for application {}".format(
727 for unit
in app
.units
:
728 app
.resolved(retry
=True)
730 async def run_action(self
, application
, action_name
, **params
):
731 """Execute an action and return an Action object."""
732 if not self
.authenticated
:
741 app
= await self
.get_application(self
.default_model
, application
)
743 # We currently only have one unit per application
744 # so use the first unit available.
747 self
.log
.debug("JujuApi: Running Action {} against Application {}".format(
752 action
= await unit
.run_action(action_name
, **params
)
754 # Wait for the action to complete
757 result
['status'] = action
.status
758 result
['action']['tag'] = action
.data
['id']
759 result
['action']['results'] = action
.results
763 async def set_config(self
, model_name
, application
, config
):
764 """Apply a configuration to the application."""
765 if not self
.authenticated
:
768 app
= await self
.get_application(model_name
, application
)
770 self
.log
.debug("JujuApi: Setting config for Application {}".format(
773 await app
.set_config(config
)
775 # Verify the config is set
776 newconf
= await app
.get_config()
778 if config
[key
] != newconf
[key
]['value']:
779 self
.log
.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key
, config
[key
], newconf
[key
]))
781 # async def set_parameter(self, parameter, value, application=None):
782 # """Set a config parameter for a service."""
783 # if not self.authenticated:
786 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
791 # return await self.apply_config(
792 # {parameter: value},
793 # application=application,
796 async def wait_for_application(self
, name
, timeout
=300):
797 """Wait for an application to become active."""
798 if not self
.authenticated
:
801 app
= await self
.get_application(self
.default_model
, name
)
804 "JujuApi: Waiting {} seconds for Application {}".format(
810 await self
.default_model
.block_until(
812 unit
.agent_status
== 'idle'
813 and unit
.workload_status
814 in ['active', 'unknown'] for unit
in app
.units