X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FN2VC.git;a=blobdiff_plain;f=n2vc%2Fvnf.py;h=a68e657c2383e49a7c07e5c441fe66d23988702b;hp=e03cca9e88685932996bfd8f73abefb5149469ad;hb=refs%2Fheads%2Ffeature7106;hpb=40047485047c68ae78cdc780a91728de323f18c3 diff --git a/n2vc/vnf.py b/n2vc/vnf.py index e03cca9..a68e657 100644 --- a/n2vc/vnf.py +++ b/n2vc/vnf.py @@ -19,7 +19,7 @@ if path not in sys.path: from juju.controller import Controller from juju.model import ModelObserver -from juju.errors import JujuAPIError +from juju.errors import JujuAPIError, JujuError # We might need this to connect to the websocket securely, but test and verify. try: @@ -43,6 +43,10 @@ class N2VCPrimitiveExecutionFailed(Exception): """Something failed while attempting to execute a primitive.""" +class NetworkServiceDoesNotExist(Exception): + """The Network Service being acted against does not exist.""" + + # Quiet the debug logging logging.getLogger('websockets.protocol').setLevel(logging.INFO) logging.getLogger('juju.client.connection').setLevel(logging.WARN) @@ -53,13 +57,12 @@ logging.getLogger('juju.machine').setLevel(logging.WARN) class VCAMonitor(ModelObserver): """Monitor state changes within the Juju Model.""" log = None - ns_name = None - applications = {} def __init__(self, ns_name): self.log = logging.getLogger(__name__) self.ns_name = ns_name + self.applications = {} def AddApplication(self, application_name, callback, *callback_args): if application_name not in self.applications: @@ -144,22 +147,34 @@ class N2VC: secret=None, artifacts=None, loop=None, + juju_public_key=None, + ca_cert=None, ): """Initialize N2VC - - :param vcaconfig dict A dictionary containing the VCA configuration - - :param artifacts str The directory where charms required by a vnfd are + :param log obj: The logging object to log to + :param server str: The IP Address or Hostname of the Juju controller + :param port int: The port of the Juju Controller + :param user str: The Juju username to authenticate with + :param secret str: The Juju password to authenticate with + :param artifacts str: The directory where charms required by a vnfd are stored. + :param loop obj: The loop to use. + :param juju_public_key str: The contents of the Juju public SSH key + :param ca_cert str: The CA certificate to use to authenticate + :Example: - n2vc = N2VC(vcaconfig={ - 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm', - 'user': 'admin', - 'ip-address': '10.44.127.137', - 'port': 17070, - 'artifacts': '/path/to/charms' - }) + client = n2vc.vnf.N2VC( + log=log, + server='10.1.1.28', + port=17070, + user='admin', + secret='admin', + artifacts='/app/storage/myvnf/charms', + loop=loop, + juju_public_key='', + ca_cert='', + ) """ # Initialize instance-level variables @@ -176,7 +191,6 @@ class N2VC: } self.models = {} - self.default_model = None # Model Observers self.monitors = {} @@ -187,6 +201,12 @@ class N2VC: self.username = "" self.secret = "" + self.juju_public_key = juju_public_key + if juju_public_key: + self._create_juju_public_key(juju_public_key) + + self.ca_cert = ca_cert + if log: self.log = log else: @@ -219,6 +239,31 @@ class N2VC: """Close any open connections.""" yield self.logout() + def _create_juju_public_key(self, public_key): + """Recreate the Juju public key on disk. + + Certain libjuju commands expect to be run from the same machine as Juju + is bootstrapped to. This method will write the public key to disk in + that location: ~/.local/share/juju/ssh/juju_id_rsa.pub + """ + # Make sure that we have a public key before writing to disk + if public_key is None or len(public_key) == 0: + if 'OSM_VCA_PUBKEY' in os.environ: + public_key = os.getenv('OSM_VCA_PUBKEY', '') + if len(public_key == 0): + return + else: + return + + path = "{}/.local/share/juju/ssh".format( + os.path.expanduser('~'), + ) + if not os.path.exists(path): + os.makedirs(path) + + with open('{}/juju_id_rsa.pub'.format(path), 'w') as f: + f.write(public_key) + def notify_callback(self, model_name, application_name, status, message, callback=None, *callback_args): try: @@ -235,26 +280,7 @@ class N2VC: return True # Public methods - async def CreateNetworkService(self, nsd): - """Create a new model to encapsulate this network service. - - Create a new model in the Juju controller to encapsulate the - charms associated with a network service. - - You can pass either the nsd record or the id of the network - service, but this method will fail without one of them. - """ - if not self.authenticated: - await self.login() - - # Ideally, we will create a unique model per network service. - # This change will require all components, i.e., LCM and SO, to use - # N2VC for 100% compatibility. If we adopt unique models for the LCM, - # services deployed via LCM would't be manageable via SO and vice versa - - return self.default_model - - async def Relate(self, ns_name, vnfd): + async def Relate(self, model_name, vnfd): """Create a relation between the charm-enabled VDUs in a VNF. The Relation mapping has two parts: the id of the vdu owning the endpoint, and the name of the endpoint. @@ -300,7 +326,7 @@ class N2VC: # Compare the named portion of the relation to the vdu's id if vdu['id'] == name: application_name = self.FormatApplicationName( - ns_name, + model_name, vnf_name, str(vnf_member_index), ) @@ -339,7 +365,7 @@ class N2VC: requires )) await self.add_relation( - ns_name, + model_name, provides, requires, ) @@ -355,7 +381,7 @@ class N2VC: Deploy the charm(s) referenced in a VNF Descriptor. - :param str model_name: The name of the network service. + :param str model_name: The name or unique id of the network service. :param str application_name: The name of the application :param dict vnfd: The name of the application :param str charm_path: The path to the Juju charm @@ -365,6 +391,8 @@ class N2VC: 'rw_mgmt_ip': '1.2.3.4', # Pass the initial-config-primitives section of the vnf or vdu 'initial-config-primitives': {...} + 'user_values': dictionary with the day-1 parameters provided at instantiation time. It will replace values + inside < >. rw_mgmt_ip will be included here also } :param dict machine_spec: A dictionary describing the machine to install to @@ -402,9 +430,6 @@ class N2VC: ########################################## # Get the model for this network service # ########################################## - # TODO: In a point release, we will use a model per deployed network - # service. In the meantime, we will always use the 'default' model. - model_name = 'default' model = await self.get_model(model_name) ######################################## @@ -418,34 +443,24 @@ class N2VC: # Register this application with the model-level event monitor # ################################################################ if callback: - self.monitors[model_name].AddApplication( + self.log.debug("JujuApi: Registering callback for {}".format( application_name, - callback, - *callback_args - ) + )) + await self.Subscribe(model_name, application_name, callback, *callback_args) ######################################################## # Check for specific machine placement (native charms) # ######################################################## to = "" if machine_spec.keys(): - if all(k in machine_spec for k in ['hostname', 'username']): - # Get the path to the previously generated ssh private key. - # Machines we're manually provisioned must have N2VC's public - # key injected, so if we don't have a keypair, raise an error. - private_key_path = "" - - # Enlist the existing machine in Juju - machine = await self.model.add_machine( - spec='ssh:{}@{}:{}'.format( - specs['host'], - specs['user'], - private_key_path, - ) - ) - # Set the machine id that the deploy below will use. + if all(k in machine_spec for k in ['host', 'user']): + # Enlist an existing machine as a Juju unit + machine = await model.add_machine(spec='ssh:{}@{}:{}'.format( + machine_spec['user'], + machine_spec['host'], + self.GetPrivateKeyPath(), + )) to = machine.id - pass ####################################### # Get the initial charm configuration # @@ -463,7 +478,8 @@ class N2VC: {'': rw_mgmt_ip} ) - self.log.debug("JujuApi: Deploying charm ({}) from {}".format( + self.log.debug("JujuApi: Deploying charm ({}/{}) from {}".format( + model_name, application_name, charm_path, to=to, @@ -494,11 +510,12 @@ class N2VC: # ####################################### # # Execute initial config primitive(s) # # ####################################### - await self.ExecuteInitialPrimitives( + uuids = await self.ExecuteInitialPrimitives( model_name, application_name, params, ) + return uuids # primitives = {} # @@ -551,9 +568,6 @@ class N2VC: if not self.authenticated: await self.login() - # FIXME: This is hard-coded until model-per-ns is added - model_name = 'default' - model = await self.get_model(model_name) results = await model.get_action_status(uuid) @@ -579,9 +593,6 @@ class N2VC: if not self.authenticated: await self.login() - # FIXME: This is hard-coded until model-per-ns is added - model_name = 'default' - model = await self.get_model(model_name) results = await model.get_action_output(uuid, 60) except Exception as e: @@ -683,15 +694,20 @@ class N2VC: else: seq = primitive['seq'] - params = {} + params_ = {} if 'parameter' in primitive: - params = primitive['parameter'] + params_ = primitive['parameter'] + + user_values = params.get("user_values", {}) + if 'rw_mgmt_ip' not in user_values: + user_values['rw_mgmt_ip'] = None + # just for backward compatibility, because it will be provided always by modern version of LCM primitives[seq] = { 'name': primitive['name'], 'parameters': self._map_primitive_parameters( - params, - {'': None} + params_, + user_values ), } @@ -719,7 +735,7 @@ class N2VC: Execute a primitive defined in the VNF descriptor. - :param str model_name: The name of the network service. + :param str model_name: The name or unique id of the network service. :param str application_name: The name of the application :param str primitive: The name of the primitive to execute. :param obj callback: A callback function to receive status changes. @@ -734,15 +750,12 @@ class N2VC: 'initial-config-primitives': {...} } """ - self.log.debug("Executing {}".format(primitive)) + self.log.debug("Executing primitive={} params={}".format(primitive, params)) uuid = None try: if not self.authenticated: await self.login() - # FIXME: This is hard-coded until model-per-ns is added - model_name = 'default' - model = await self.get_model(model_name) if primitive == 'config': @@ -787,7 +800,7 @@ class N2VC: app = await self.get_application(model, application_name) if app: # Remove this application from event monitoring - self.monitors[model_name].RemoveApplication(application_name) + await self.Unsubscribe(model_name, application_name) # self.notify_callback(model_name, application_name, "removing", callback, *callback_args) self.log.debug( @@ -795,11 +808,13 @@ class N2VC: ) await app.remove() - # Notify the callback that this charm has been removed. + await self.disconnect_model(self.monitors[model_name]) + self.notify_callback( model_name, application_name, "removed", + "Removing charm {}".format(application_name), callback, *callback_args, ) @@ -809,13 +824,70 @@ class N2VC: self.log.debug(e) raise e - async def DestroyNetworkService(self, nsd): - raise NotImplementedError() + async def CreateNetworkService(self, ns_uuid): + """Create a new Juju model for the Network Service. + + Creates a new Model in the Juju Controller. + + :param str ns_uuid: A unique id representing an instaance of a + Network Service. + + :returns: True if the model was created. Raises JujuError on failure. + """ + if not self.authenticated: + await self.login() + + models = await self.controller.list_models() + if ns_uuid not in models: + try: + self.models[ns_uuid] = await self.controller.add_model( + ns_uuid + ) + except JujuError as e: + if "already exists" not in e.message: + raise e + + # Create an observer for this model + await self.create_model_monitor(ns_uuid) + + return True + + async def DestroyNetworkService(self, ns_uuid): + """Destroy a Network Service. + + Destroy the Network Service and any deployed charms. + + :param ns_uuid The unique id of the Network Service + + :returns: True if the model was created. Raises JujuError on failure. + """ + + # Do not delete the default model. The default model was used by all + # Network Services, prior to the implementation of a model per NS. + if ns_uuid.lower() == "default": + return False + + if not self.authenticated: + self.log.debug("Authenticating with Juju") + await self.login() + + # Disconnect from the Model + if ns_uuid in self.models: + await self.disconnect_model(self.models[ns_uuid]) + + try: + await self.controller.destroy_models(ns_uuid) + except JujuError: + raise NetworkServiceDoesNotExist( + "The Network Service '{}' does not exist".format(ns_uuid) + ) + + return True async def GetMetrics(self, model_name, application_name): """Get the metrics collected by the VCA. - :param model_name The name of the model + :param model_name The name or unique id of the network service :param application_name The name of the application """ metrics = {} @@ -833,14 +905,41 @@ class N2VC: return True return False + async def Subscribe(self, ns_name, application_name, callback, *callback_args): + """Subscribe to callbacks for an application. + + :param ns_name str: The name of the Network Service + :param application_name str: The name of the application + :param callback obj: The callback method + :param callback_args list: The list of arguments to append to calls to + the callback method + """ + self.monitors[ns_name].AddApplication( + application_name, + callback, + *callback_args + ) + + async def Unsubscribe(self, ns_name, application_name): + """Unsubscribe to callbacks for an application. + + Unsubscribes the caller from notifications from a deployed application. + + :param ns_name str: The name of the Network Service + :param application_name str: The name of the application + """ + self.monitors[ns_name].RemoveApplication( + application_name, + ) + # Non-public methods async def add_relation(self, model_name, relation1, relation2): """ Add a relation between two application endpoints. - :param str model_name Name of the network service. - :param str relation1 '[:]' - :param str relation12 '[:]' + :param str model_name: The name or unique id of the network service + :param str relation1: '[:]' + :param str relation2: '[:]' """ if not self.authenticated: @@ -889,27 +988,45 @@ class N2VC: return config - def _map_primitive_parameters(self, parameters, values): + def _map_primitive_parameters(self, parameters, user_values): params = {} for parameter in parameters: param = str(parameter['name']) - value = None + value = parameter.get('value') + + # map parameters inside a < >; e.g. . with the provided user_values. + # Must exist at user_values except if there is a default value + if isinstance(value, str) and value.startswith("<") and value.endswith(">"): + if parameter['value'][1:-1] in user_values: + value = user_values[parameter['value'][1:-1]] + elif 'default-value' in parameter: + value = parameter['default-value'] + else: + raise KeyError("parameter {}='{}' not supplied ".format(param, value)) + + # If there's no value, use the default-value (if set) + if value is None and 'default-value' in parameter: + value = parameter['default-value'] # Typecast parameter value, if present - if 'data-type' in parameter: - paramtype = str(parameter['data-type']).lower() + paramtype = "string" + try: + if 'data-type' in parameter: + paramtype = str(parameter['data-type']).lower() - if paramtype == "integer": - value = int(parameter['value']) - elif paramtype == "boolean": - value = bool(parameter['value']) + if paramtype == "integer": + value = int(value) + elif paramtype == "boolean": + value = bool(value) + else: + value = str(value) else: - value = str(parameter['value']) + # If there's no data-type, assume the value is a string + value = str(value) + except ValueError: + raise ValueError("parameter {}='{}' cannot be converted to type {}".format(param, value, paramtype)) - if parameter['value'] == "": - params[param] = str(values[parameter['value']]) - else: - params[param] = value + params[param] = value return params def _get_config_from_yang(self, config_primitive, values): @@ -926,7 +1043,6 @@ class N2VC: return config - @staticmethod def FormatApplicationName(self, *args): """ Generate a Juju-compatible Application name @@ -942,7 +1058,6 @@ class N2VC: FormatApplicationName("ping_pong_ns", "ping_vnf", "a") """ - appname = "" for c in "-".join(list(args)): if c.isdigit(): @@ -950,7 +1065,7 @@ class N2VC: elif not c.isalpha(): c = "-" appname += c - return re.sub('\-+', '-', appname.lower()) + return re.sub('-+', '-', appname.lower()) # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0): # """Format the name of the application @@ -990,7 +1105,7 @@ class N2VC: return app - async def get_model(self, model_name='default'): + async def get_model(self, model_name): """Get a model from the Juju Controller. Note: Model objects returned must call disconnected() before it goes @@ -999,16 +1114,39 @@ class N2VC: await self.login() if model_name not in self.models: - self.models[model_name] = await self.controller.get_model( - model_name, - ) + # Get the models in the controller + models = await self.controller.list_models() + + if model_name not in models: + try: + self.models[model_name] = await self.controller.add_model( + model_name + ) + except JujuError as e: + if "already exists" not in e.message: + raise e + else: + self.models[model_name] = await self.controller.get_model( + model_name + ) + self.refcount['model'] += 1 # Create an observer for this model + await self.create_model_monitor(model_name) + + return self.models[model_name] + + async def create_model_monitor(self, model_name): + """Create a monitor for the model, if none exists.""" + if not self.authenticated: + await self.login() + + if model_name not in self.monitors: self.monitors[model_name] = VCAMonitor(model_name) self.models[model_name].add_observer(self.monitors[model_name]) - return self.models[model_name] + return True async def login(self): """Login to the Juju controller.""" @@ -1020,7 +1158,6 @@ class N2VC: self.log.debug("JujuApi: Logging into controller") - cacert = None self.controller = Controller(loop=self.loop) if self.secret: @@ -1036,7 +1173,7 @@ class N2VC: endpoint=self.endpoint, username=self.user, password=self.secret, - cacert=cacert, + cacert=self.ca_cert, ) self.refcount['controller'] += 1 else: @@ -1056,21 +1193,11 @@ class N2VC: async def logout(self): """Logout of the Juju controller.""" if not self.authenticated: - return + return False try: - if self.default_model: - self.log.debug("Disconnecting model {}".format( - self.default_model - )) - await self.default_model.disconnect() - self.refcount['model'] -= 1 - self.default_model = None - for model in self.models: - await self.models[model].disconnect() - self.refcount['model'] -= 1 - self.models[model] = None + await self.disconnect_model(model) if self.controller: self.log.debug("Disconnecting controller {}".format( @@ -1089,6 +1216,15 @@ class N2VC: "Fatal error logging out of Juju Controller: {}".format(e) ) raise e + return True + + async def disconnect_model(self, model): + self.log.debug("Disconnecting model {}".format(model)) + if model in self.models: + print("Disconnecting model") + await self.models[model].disconnect() + self.refcount['model'] -= 1 + self.models[model] = None # async def remove_application(self, name): # """Remove the application.""" @@ -1119,12 +1255,14 @@ class N2VC: finally: await m.disconnect() - async def resolve_error(self, application=None): + async def resolve_error(self, model_name, application=None): """Resolve units in error state.""" if not self.authenticated: await self.login() - app = await self.get_application(self.default_model, application) + model = await self.get_model(model_name) + + app = await self.get_application(model, application) if app: self.log.debug( "JujuApi: Resolving errors for application {}".format( @@ -1135,7 +1273,7 @@ class N2VC: for unit in app.units: app.resolved(retry=True) - async def run_action(self, application, action_name, **params): + async def run_action(self, model_name, application, action_name, **params): """Execute an action and return an Action object.""" if not self.authenticated: await self.login() @@ -1146,7 +1284,10 @@ class N2VC: 'results': None, } } - app = await self.get_application(self.default_model, application) + + model = await self.get_model(model_name) + + app = await self.get_application(model, application) if app: # We currently only have one unit per application # so use the first unit available. @@ -1209,14 +1350,10 @@ class N2VC: if not self.authenticated: await self.login() - # TODO: In a point release, we will use a model per deployed network - # service. In the meantime, we will always use the 'default' model. - model_name = 'default' model = await self.get_model(model_name) app = await self.get_application(model, application_name) self.log.debug("Application: {}".format(app)) - # app = await self.get_application(model_name, application_name) if app: self.log.debug( "JujuApi: Waiting {} seconds for Application {}".format(