1 # Copyright 2019 Canonical Ltd.
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
7 # http://www.apache.org/licenses/LICENSE-2.0
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
27 import n2vc
.exceptions
28 from n2vc
.provisioner
import SSHProvisioner
30 # FIXME: this should load the juju inside or modules without having to
31 # explicitly install it. Check why it's not working.
32 # Load our subtree of the juju library
33 # path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
34 # path = os.path.join(path, "modules/libjuju/")
35 # if path not in sys.path:
36 # sys.path.insert(1, path)
38 from juju
.client
import client
39 from juju
.controller
import Controller
40 from juju
.model
import ModelObserver
41 from juju
.errors
import JujuAPIError
, JujuError
44 # We might need this to connect to the websocket securely, but test and verify.
46 ssl
._create
_default
_https
_context
= ssl
._create
_unverified
_context
47 except AttributeError:
48 # Legacy Python doesn't verify by default (see pep-0476)
49 # https://www.python.org/dev/peps/pep-0476/
54 # Deprecated. Please use n2vc.exceptions namespace.
55 class JujuCharmNotFound(Exception):
56 """The Charm can't be found or is not readable."""
59 class JujuApplicationExists(Exception):
60 """The Application already exists."""
63 class N2VCPrimitiveExecutionFailed(Exception):
64 """Something failed while attempting to execute a primitive."""
67 class NetworkServiceDoesNotExist(Exception):
68 """The Network Service being acted against does not exist."""
71 class PrimitiveDoesNotExist(Exception):
72 """The Primitive being executed does not exist."""
75 # Quiet the debug logging
76 logging
.getLogger('websockets.protocol').setLevel(logging
.INFO
)
77 logging
.getLogger('juju.client.connection').setLevel(logging
.WARN
)
78 logging
.getLogger('juju.model').setLevel(logging
.WARN
)
79 logging
.getLogger('juju.machine').setLevel(logging
.WARN
)
82 class VCAMonitor(ModelObserver
):
83 """Monitor state changes within the Juju Model."""
86 def __init__(self
, ns_name
):
87 self
.log
= logging
.getLogger(__name__
)
89 self
.ns_name
= ns_name
90 self
.applications
= {}
92 def AddApplication(self
, application_name
, callback
, *callback_args
):
93 if application_name
not in self
.applications
:
94 self
.applications
[application_name
] = {
96 'callback_args': callback_args
99 def RemoveApplication(self
, application_name
):
100 if application_name
in self
.applications
:
101 del self
.applications
[application_name
]
103 async def on_change(self
, delta
, old
, new
, model
):
104 """React to changes in the Juju model."""
106 if delta
.entity
== "unit":
107 # Ignore change events from other applications
108 if delta
.data
['application'] not in self
.applications
.keys():
113 application_name
= delta
.data
['application']
115 callback
= self
.applications
[application_name
]['callback']
117 self
.applications
[application_name
]['callback_args']
120 # Fire off a callback with the application state
124 delta
.data
['application'],
126 new
.workload_status_message
,
130 # This is a charm being removed
134 delta
.data
['application'],
138 except Exception as e
:
139 self
.log
.debug("[1] notify_callback exception: {}".format(e
))
141 elif delta
.entity
== "action":
142 # TODO: Decide how we want to notify the user of actions
144 # uuid = delta.data['id'] # The Action's unique id
145 # msg = delta.data['message'] # The output of the action
147 # if delta.data['status'] == "pending":
148 # # The action is queued
150 # elif delta.data['status'] == "completed""
151 # # The action was successful
153 # elif delta.data['status'] == "failed":
154 # # The action failed.
162 # Create unique models per network service
163 # Document all public functions
175 juju_public_key
=None,
181 Initializes the N2VC object, allowing the caller to interoperate with the VCA.
184 :param log obj: The logging object to log to
185 :param server str: The IP Address or Hostname of the Juju controller
186 :param port int: The port of the Juju Controller
187 :param user str: The Juju username to authenticate with
188 :param secret str: The Juju password to authenticate with
189 :param artifacts str: The directory where charms required by a vnfd are
191 :param loop obj: The loop to use.
192 :param juju_public_key str: The contents of the Juju public SSH key
193 :param ca_cert str: The CA certificate to use to authenticate
194 :param api_proxy str: The IP of the host machine
197 client = n2vc.vnf.N2VC(
203 artifacts='/app/storage/myvnf/charms',
205 juju_public_key='<contents of the juju public key>',
206 ca_cert='<contents of CA certificate>',
207 api_proxy='192.168.1.155'
211 # Initialize instance-level variables
214 self
.controller
= None
215 self
.connecting
= False
216 self
.authenticated
= False
217 self
.api_proxy
= api_proxy
222 self
.log
= logging
.getLogger(__name__
)
241 self
.juju_public_key
= juju_public_key
243 self
._create
_juju
_public
_key
(juju_public_key
)
245 self
.juju_public_key
= ''
247 # TODO: Verify ca_cert is valid before using. VCA will crash
248 # if the ca_cert isn't formatted correctly.
249 def base64_to_cacert(b64string
):
250 """Convert the base64-encoded string containing the VCA CACERT.
256 cacert
= base64
.b64decode(b64string
).decode("utf-8")
263 except binascii
.Error
as e
:
264 self
.log
.debug("Caught binascii.Error: {}".format(e
))
265 raise n2vc
.exceptions
.InvalidCACertificate("Invalid CA Certificate")
269 self
.ca_cert
= base64_to_cacert(ca_cert
)
270 # self.ca_cert = None
273 # Quiet websocket traffic
274 logging
.getLogger('websockets.protocol').setLevel(logging
.INFO
)
275 logging
.getLogger('juju.client.connection').setLevel(logging
.WARN
)
276 logging
.getLogger('model').setLevel(logging
.WARN
)
277 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
279 self
.log
.debug('JujuApi: instantiated')
285 if user
.startswith('user-'):
288 self
.user
= 'user-{}'.format(user
)
290 self
.endpoint
= '%s:%d' % (server
, int(port
))
292 self
.artifacts
= artifacts
294 self
.loop
= loop
or asyncio
.get_event_loop()
297 """Close any open connections."""
300 def _create_juju_public_key(self
, public_key
):
301 """Recreate the Juju public key on disk.
303 Certain libjuju commands expect to be run from the same machine as Juju
304 is bootstrapped to. This method will write the public key to disk in
305 that location: ~/.local/share/juju/ssh/juju_id_rsa.pub
307 # Make sure that we have a public key before writing to disk
308 if public_key
is None or len(public_key
) == 0:
309 if 'OSM_VCA_PUBKEY' in os
.environ
:
310 public_key
= os
.getenv('OSM_VCA_PUBKEY', '')
311 if len(public_key
== 0):
316 path
= "{}/.local/share/juju/ssh".format(
317 os
.path
.expanduser('~'),
319 if not os
.path
.exists(path
):
322 with
open('{}/juju_id_rsa.pub'.format(path
), 'w') as f
:
325 def notify_callback(self
, model_name
, application_name
, status
, message
,
326 callback
=None, *callback_args
):
335 except Exception as e
:
336 self
.log
.error("[0] notify_callback exception {}".format(e
))
341 async def Relate(self
, model_name
, vnfd
):
342 """Create a relation between the charm-enabled VDUs in a VNF.
344 The Relation mapping has two parts: the id of the vdu owning the endpoint, and the name of the endpoint.
350 - provides: dataVM:db
353 This tells N2VC that the charm referred to by the dataVM vdu offers a relation named 'db', and the mgmtVM vdu has an 'app' endpoint that should be connected to a database.
355 :param str ns_name: The name of the network service.
356 :param dict vnfd: The parsed yaml VNF descriptor.
359 # Currently, the call to Relate() is made automatically after the
360 # deployment of each charm; if the relation depends on a charm that
361 # hasn't been deployed yet, the call will fail silently. This will
362 # prevent an API breakage, with the intent of making this an explicitly
363 # required call in a more object-oriented refactor of the N2VC API.
366 vnf_config
= vnfd
.get("vnf-configuration")
368 juju
= vnf_config
['juju']
370 configs
.append(vnf_config
)
372 for vdu
in vnfd
['vdu']:
373 vdu_config
= vdu
.get('vdu-configuration')
375 juju
= vdu_config
['juju']
377 configs
.append(vdu_config
)
379 def _get_application_name(name
):
380 """Get the application name that's mapped to a vnf/vdu."""
382 vnf_name
= vnfd
['name']
384 for vdu
in vnfd
.get('vdu'):
385 # Compare the named portion of the relation to the vdu's id
386 if vdu
['id'] == name
:
387 application_name
= self
.FormatApplicationName(
390 str(vnf_member_index
),
392 return application_name
394 vnf_member_index
+= 1
398 # Loop through relations
402 if 'vca-relationships' in juju
and 'relation' in juju
['vca-relationships']:
403 for rel
in juju
['vca-relationships']['relation']:
406 # get the application name for the provides
407 (name
, endpoint
) = rel
['provides'].split(':')
408 application_name
= _get_application_name(name
)
410 provides
= "{}:{}".format(
415 # get the application name for thr requires
416 (name
, endpoint
) = rel
['requires'].split(':')
417 application_name
= _get_application_name(name
)
419 requires
= "{}:{}".format(
423 self
.log
.debug("Relation: {} <-> {}".format(
427 await self
.add_relation(
432 except Exception as e
:
433 self
.log
.debug("Exception: {}".format(e
))
437 async def DeployCharms(self
, model_name
, application_name
, vnfd
,
438 charm_path
, params
={}, machine_spec
={},
439 callback
=None, *callback_args
):
440 """Deploy one or more charms associated with a VNF.
442 Deploy the charm(s) referenced in a VNF Descriptor.
444 :param str model_name: The name or unique id of the network service.
445 :param str application_name: The name of the application
446 :param dict vnfd: The name of the application
447 :param str charm_path: The path to the Juju charm
448 :param dict params: A dictionary of runtime parameters
451 'rw_mgmt_ip': '1.2.3.4',
452 # Pass the initial-config-primitives section of the vnf or vdu
453 'initial-config-primitives': {...}
454 'user_values': dictionary with the day-1 parameters provided at instantiation time. It will replace values
455 inside < >. rw_mgmt_ip will be included here also
457 :param dict machine_spec: A dictionary describing the machine to
461 'hostname': '1.2.3.4',
462 'username': 'ubuntu',
464 :param obj callback: A callback function to receive status changes.
465 :param tuple callback_args: A list of arguments to be passed to the
469 ########################################################
470 # Verify the path to the charm exists and is readable. #
471 ########################################################
472 if not os
.path
.exists(charm_path
):
473 self
.log
.debug("Charm path doesn't exist: {}".format(charm_path
))
474 self
.notify_callback(
481 raise JujuCharmNotFound("No artifacts configured.")
483 ################################
484 # Login to the Juju controller #
485 ################################
486 if not self
.authenticated
:
487 self
.log
.debug("Authenticating with Juju")
490 ##########################################
491 # Get the model for this network service #
492 ##########################################
493 model
= await self
.get_model(model_name
)
495 ########################################
496 # Verify the application doesn't exist #
497 ########################################
498 app
= await self
.get_application(model
, application_name
)
500 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name
, model_name
))
502 ################################################################
503 # Register this application with the model-level event monitor #
504 ################################################################
506 self
.log
.debug("JujuApi: Registering callback for {}".format(
509 await self
.Subscribe(model_name
, application_name
, callback
, *callback_args
)
511 #######################################
512 # Get the initial charm configuration #
513 #######################################
516 if 'rw_mgmt_ip' in params
:
517 rw_mgmt_ip
= params
['rw_mgmt_ip']
519 if 'initial-config-primitive' not in params
:
520 params
['initial-config-primitive'] = {}
522 initial_config
= self
._get
_config
_from
_dict
(
523 params
['initial-config-primitive'],
524 {'<rw_mgmt_ip>': rw_mgmt_ip
}
527 ########################################################
528 # Check for specific machine placement (native charms) #
529 ########################################################
533 if machine_spec
.keys():
534 if all(k
in machine_spec
for k
in ['hostname', 'username']):
536 # Allow series to be derived from the native charm
539 self
.log
.debug("Provisioning manual machine {}@{}".format(
540 machine_spec
['username'],
541 machine_spec
['hostname'],
544 """Native Charm support
546 Taking a bare VM (assumed to be an Ubuntu cloud image),
547 the provisioning process will:
548 - Create an ubuntu user w/sudo access
550 - Detect architecture
551 - Download and install Juju agent from controller
553 - Add an iptables rule to route traffic to the API proxy
556 to
= await self
.provision_machine(
557 model_name
=model_name
,
558 username
=machine_spec
['username'],
559 hostname
=machine_spec
['hostname'],
560 private_key_path
=self
.GetPrivateKeyPath(),
562 self
.log
.debug("Provisioned machine id {}".format(to
))
564 # TODO: If to is none, raise an exception
566 # The native charm won't have the sshproxy layer, typically, but LCM uses the config primitive
567 # to interpret what the values are. That's a gap to fill.
570 The ssh-* config parameters are unique to the sshproxy layer,
571 which most native charms will not be aware of.
573 Setting invalid config parameters will cause the deployment to
576 For the moment, we will strip the ssh-* parameters from native
577 charms, until the feature gap is addressed in the information
581 # Native charms don't include the ssh-* config values, so strip them
582 # from the initial_config, otherwise the deploy will raise an error.
583 # self.log.debug("Removing ssh-* from initial-config")
584 for k
in ['ssh-hostname', 'ssh-username', 'ssh-password']:
585 if k
in initial_config
:
586 self
.log
.debug("Removing parameter {}".format(k
))
587 del initial_config
[k
]
589 self
.log
.debug("JujuApi: Deploying charm ({}/{}) from {} to {}".format(
596 ########################################################
597 # Deploy the charm and apply the initial configuration #
598 ########################################################
599 app
= await model
.deploy(
600 # We expect charm_path to be either the path to the charm on disk
601 # or in the format of cs:series/name
603 # This is the formatted, unique name for this charm
604 application_name
=application_name
,
605 # Proxy charms should use the current LTS. This will need to be
606 # changed for native charms.
608 # Apply the initial 'config' primitive during deployment
609 config
=initial_config
,
610 # Where to deploy the charm to.
614 #############################
615 # Map the vdu id<->app name #
616 #############################
618 await self
.Relate(model_name
, vnfd
)
619 except KeyError as ex
:
620 # We don't currently support relations between NS and VNF/VDU charms
621 self
.log
.warn("[N2VC] Relations not supported: {}".format(ex
))
622 except Exception as ex
:
623 # This may happen if not all of the charms needed by the relation
624 # are ready. We can safely ignore this, because Relate will be
625 # retried when the endpoint of the relation is deployed.
626 self
.log
.warn("[N2VC] Relations not ready")
628 # #######################################
629 # # Execute initial config primitive(s) #
630 # #######################################
631 uuids
= await self
.ExecuteInitialPrimitives(
640 # # Build a sequential list of the primitives to execute
641 # for primitive in params['initial-config-primitive']:
643 # if primitive['name'] == 'config':
644 # # This is applied when the Application is deployed
647 # seq = primitive['seq']
650 # if 'parameter' in primitive:
651 # params = primitive['parameter']
653 # primitives[seq] = {
654 # 'name': primitive['name'],
655 # 'parameters': self._map_primitive_parameters(
657 # {'<rw_mgmt_ip>': rw_mgmt_ip}
661 # for primitive in sorted(primitives):
662 # await self.ExecutePrimitive(
665 # primitives[primitive]['name'],
668 # **primitives[primitive]['parameters'],
670 # except N2VCPrimitiveExecutionFailed as e:
672 # "[N2VC] Exception executing primitive: {}".format(e)
676 async def GetPrimitiveStatus(self
, model_name
, uuid
):
677 """Get the status of an executed Primitive.
679 The status of an executed Primitive will be one of three values:
686 if not self
.authenticated
:
689 model
= await self
.get_model(model_name
)
691 results
= await model
.get_action_status(uuid
)
694 status
= results
[uuid
]
696 except Exception as e
:
698 "Caught exception while getting primitive status: {}".format(e
)
700 raise N2VCPrimitiveExecutionFailed(e
)
704 async def GetPrimitiveOutput(self
, model_name
, uuid
):
705 """Get the output of an executed Primitive.
707 Note: this only returns output for a successfully executed primitive.
711 if not self
.authenticated
:
714 model
= await self
.get_model(model_name
)
715 results
= await model
.get_action_output(uuid
, 60)
716 except Exception as e
:
718 "Caught exception while getting primitive status: {}".format(e
)
720 raise N2VCPrimitiveExecutionFailed(e
)
724 # async def ProvisionMachine(self, model_name, hostname, username):
725 # """Provision machine for usage with Juju.
727 # Provisions a previously instantiated machine for use with Juju.
730 # if not self.authenticated:
733 # # FIXME: This is hard-coded until model-per-ns is added
734 # model_name = 'default'
736 # model = await self.get_model(model_name)
737 # model.add_machine(spec={})
739 # machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
746 # except Exception as e:
748 # "Caught exception while getting primitive status: {}".format(e)
750 # raise N2VCPrimitiveExecutionFailed(e)
752 def GetPrivateKeyPath(self
):
753 homedir
= os
.environ
['HOME']
754 sshdir
= "{}/.ssh".format(homedir
)
755 private_key_path
= "{}/id_n2vc_rsa".format(sshdir
)
756 return private_key_path
758 async def GetPublicKey(self
):
759 """Get the N2VC SSH public key.abs
761 Returns the SSH public key, to be injected into virtual machines to
762 be managed by the VCA.
764 The first time this is run, a ssh keypair will be created. The public
765 key is injected into a VM so that we can provision the machine with
766 Juju, after which Juju will communicate with the VM directly via the
771 # Find the path to where we expect our key to live.
772 homedir
= os
.environ
['HOME']
773 sshdir
= "{}/.ssh".format(homedir
)
774 if not os
.path
.exists(sshdir
):
777 private_key_path
= "{}/id_n2vc_rsa".format(sshdir
)
778 public_key_path
= "{}.pub".format(private_key_path
)
780 # If we don't have a key generated, generate it.
781 if not os
.path
.exists(private_key_path
):
782 cmd
= "ssh-keygen -t {} -b {} -N '' -f {}".format(
787 subprocess
.check_output(shlex
.split(cmd
))
789 # Read the public key
790 with
open(public_key_path
, "r") as f
:
791 public_key
= f
.readline()
795 async def ExecuteInitialPrimitives(self
, model_name
, application_name
,
796 params
, callback
=None, *callback_args
):
797 """Execute multiple primitives.
799 Execute multiple primitives as declared in initial-config-primitive.
800 This is useful in cases where the primitives initially failed -- for
801 example, if the charm is a proxy but the proxy hasn't been configured
807 # Build a sequential list of the primitives to execute
808 for primitive
in params
['initial-config-primitive']:
810 if primitive
['name'] == 'config':
813 seq
= primitive
['seq']
816 if 'parameter' in primitive
:
817 params_
= primitive
['parameter']
819 user_values
= params
.get("user_values", {})
820 if 'rw_mgmt_ip' not in user_values
:
821 user_values
['rw_mgmt_ip'] = None
822 # just for backward compatibility, because it will be provided always by modern version of LCM
825 'name': primitive
['name'],
826 'parameters': self
._map
_primitive
_parameters
(
832 for primitive
in sorted(primitives
):
834 # self.log.debug("Queuing action {}".format(primitives[primitive]['name']))
836 await self
.ExecutePrimitive(
839 primitives
[primitive
]['name'],
842 **primitives
[primitive
]['parameters'],
845 except PrimitiveDoesNotExist
as e
:
846 self
.log
.debug("Ignoring exception PrimitiveDoesNotExist: {}".format(e
))
848 except Exception as e
:
849 self
.log
.debug("XXXXXXXXXXXXXXXXXXXXXXXXX Unexpected exception: {}".format(e
))
852 except N2VCPrimitiveExecutionFailed
as e
:
854 "[N2VC] Exception executing primitive: {}".format(e
)
859 async def ExecutePrimitive(self
, model_name
, application_name
, primitive
,
860 callback
, *callback_args
, **params
):
861 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
863 Execute a primitive defined in the VNF descriptor.
865 :param str model_name: The name or unique id of the network service.
866 :param str application_name: The name of the application
867 :param str primitive: The name of the primitive to execute.
868 :param obj callback: A callback function to receive status changes.
869 :param tuple callback_args: A list of arguments to be passed to the
871 :param dict params: A dictionary of key=value pairs representing the
872 primitive's parameters
875 'rw_mgmt_ip': '1.2.3.4',
876 # Pass the initial-config-primitives section of the vnf or vdu
877 'initial-config-primitives': {...}
880 self
.log
.debug("Executing primitive={} params={}".format(primitive
, params
))
883 if not self
.authenticated
:
886 model
= await self
.get_model(model_name
)
888 if primitive
== 'config':
889 # config is special, and expecting params to be a dictionary
890 await self
.set_config(
896 app
= await self
.get_application(model
, application_name
)
898 # Does this primitive exist?
899 actions
= await app
.get_actions()
901 if primitive
not in actions
.keys():
902 raise PrimitiveDoesNotExist("Primitive {} does not exist".format(primitive
))
904 # Run against the first (and probably only) unit in the app
907 action
= await unit
.run_action(primitive
, **params
)
909 except PrimitiveDoesNotExist
as e
:
910 # Catch and raise this exception if it's thrown from the inner block
912 except Exception as e
:
913 # An unexpected exception was caught
915 "Caught exception while executing primitive: {}".format(e
)
917 raise N2VCPrimitiveExecutionFailed(e
)
920 async def RemoveCharms(self
, model_name
, application_name
, callback
=None,
922 """Remove a charm from the VCA.
924 Remove a charm referenced in a VNF Descriptor.
926 :param str model_name: The name of the network service.
927 :param str application_name: The name of the application
928 :param obj callback: A callback function to receive status changes.
929 :param tuple callback_args: A list of arguments to be passed to the
933 if not self
.authenticated
:
936 model
= await self
.get_model(model_name
)
937 app
= await self
.get_application(model
, application_name
)
939 # Remove this application from event monitoring
940 await self
.Unsubscribe(model_name
, application_name
)
942 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
944 "Removing the application {}".format(application_name
)
948 # await self.disconnect_model(self.monitors[model_name])
950 self
.notify_callback(
954 "Removing charm {}".format(application_name
),
959 except Exception as e
:
960 print("Caught exception: {}".format(e
))
964 async def CreateNetworkService(self
, ns_uuid
):
965 """Create a new Juju model for the Network Service.
967 Creates a new Model in the Juju Controller.
969 :param str ns_uuid: A unique id representing an instaance of a
972 :returns: True if the model was created. Raises JujuError on failure.
974 if not self
.authenticated
:
977 models
= await self
.controller
.list_models()
978 if ns_uuid
not in models
:
980 await self
.get_model(ns_uuid
)
984 async def DestroyNetworkService(self
, ns_uuid
):
985 """Destroy a Network Service.
987 Destroy the Network Service and any deployed charms.
989 :param ns_uuid The unique id of the Network Service
991 :returns: True if the model was created. Raises JujuError on failure.
994 # Do not delete the default model. The default model was used by all
995 # Network Services, prior to the implementation of a model per NS.
996 if ns_uuid
.lower() == "default":
999 if not self
.authenticated
:
1002 models
= await self
.controller
.list_models()
1003 if ns_uuid
in models
:
1004 model
= await self
.controller
.get_model(ns_uuid
)
1006 for application
in model
.applications
:
1007 app
= model
.applications
[application
]
1009 await self
.RemoveCharms(ns_uuid
, application
)
1011 self
.log
.debug("Unsubscribing Watcher for {}".format(application
))
1012 await self
.Unsubscribe(ns_uuid
, application
)
1014 self
.log
.debug("Waiting for application to terminate")
1017 await model
.block_until(
1019 unit
.workload_status
in ['terminated'] for unit
in app
.units
1023 except Exception as e
:
1024 self
.log
.debug("Timed out waiting for {} to terminate.".format(application
))
1026 for machine
in model
.machines
:
1028 self
.log
.debug("Destroying machine {}".format(machine
))
1029 await model
.machines
[machine
].destroy(force
=True)
1030 except JujuAPIError
as e
:
1031 if 'does not exist' in str(e
):
1032 # Our cached model may be stale, because the machine
1033 # has already been removed. It's safe to continue.
1036 self
.log
.debug("Caught exception: {}".format(e
))
1039 # Disconnect from the Model
1040 if ns_uuid
in self
.models
:
1041 self
.log
.debug("Disconnecting model {}".format(ns_uuid
))
1042 # await self.disconnect_model(self.models[ns_uuid])
1043 await self
.disconnect_model(ns_uuid
)
1046 self
.log
.debug("Destroying model {}".format(ns_uuid
))
1047 await self
.controller
.destroy_models(ns_uuid
)
1049 raise NetworkServiceDoesNotExist(
1050 "The Network Service '{}' does not exist".format(ns_uuid
)
1055 async def GetMetrics(self
, model_name
, application_name
):
1056 """Get the metrics collected by the VCA.
1058 :param model_name The name or unique id of the network service
1059 :param application_name The name of the application
1062 model
= await self
.get_model(model_name
)
1063 app
= await self
.get_application(model
, application_name
)
1065 metrics
= await app
.get_metrics()
1069 async def HasApplication(self
, model_name
, application_name
):
1070 model
= await self
.get_model(model_name
)
1071 app
= await self
.get_application(model
, application_name
)
1076 async def Subscribe(self
, ns_name
, application_name
, callback
, *callback_args
):
1077 """Subscribe to callbacks for an application.
1079 :param ns_name str: The name of the Network Service
1080 :param application_name str: The name of the application
1081 :param callback obj: The callback method
1082 :param callback_args list: The list of arguments to append to calls to
1085 self
.monitors
[ns_name
].AddApplication(
1091 async def Unsubscribe(self
, ns_name
, application_name
):
1092 """Unsubscribe to callbacks for an application.
1094 Unsubscribes the caller from notifications from a deployed application.
1096 :param ns_name str: The name of the Network Service
1097 :param application_name str: The name of the application
1099 self
.monitors
[ns_name
].RemoveApplication(
1103 # Non-public methods
1104 async def add_relation(self
, model_name
, relation1
, relation2
):
1106 Add a relation between two application endpoints.
1108 :param str model_name: The name or unique id of the network service
1109 :param str relation1: '<application>[:<relation_name>]'
1110 :param str relation2: '<application>[:<relation_name>]'
1113 if not self
.authenticated
:
1116 m
= await self
.get_model(model_name
)
1118 await m
.add_relation(relation1
, relation2
)
1119 except JujuAPIError
as e
:
1120 # If one of the applications in the relationship doesn't exist,
1121 # or the relation has already been added, let the operation fail
1123 if 'not found' in e
.message
:
1125 if 'already exists' in e
.message
:
1130 # async def apply_config(self, config, application):
1131 # """Apply a configuration to the application."""
1132 # print("JujuApi: Applying configuration to {}.".format(
1135 # return await self.set_config(application=application, config=config)
1137 def _get_config_from_dict(self
, config_primitive
, values
):
1138 """Transform the yang config primitive to dict.
1147 for primitive
in config_primitive
:
1148 if primitive
['name'] == 'config':
1149 # config = self._map_primitive_parameters()
1150 for parameter
in primitive
['parameter']:
1151 param
= str(parameter
['name'])
1152 if parameter
['value'] == "<rw_mgmt_ip>":
1153 config
[param
] = str(values
[parameter
['value']])
1155 config
[param
] = str(parameter
['value'])
1159 def _map_primitive_parameters(self
, parameters
, user_values
):
1161 for parameter
in parameters
:
1162 param
= str(parameter
['name'])
1163 value
= parameter
.get('value')
1165 # map parameters inside a < >; e.g. <rw_mgmt_ip>. with the provided user_values.
1166 # Must exist at user_values except if there is a default value
1167 if isinstance(value
, str) and value
.startswith("<") and value
.endswith(">"):
1168 if parameter
['value'][1:-1] in user_values
:
1169 value
= user_values
[parameter
['value'][1:-1]]
1170 elif 'default-value' in parameter
:
1171 value
= parameter
['default-value']
1173 raise KeyError("parameter {}='{}' not supplied ".format(param
, value
))
1175 # If there's no value, use the default-value (if set)
1176 if value
is None and 'default-value' in parameter
:
1177 value
= parameter
['default-value']
1179 # Typecast parameter value, if present
1180 paramtype
= "string"
1182 if 'data-type' in parameter
:
1183 paramtype
= str(parameter
['data-type']).lower()
1185 if paramtype
== "integer":
1187 elif paramtype
== "boolean":
1192 # If there's no data-type, assume the value is a string
1195 raise ValueError("parameter {}='{}' cannot be converted to type {}".format(param
, value
, paramtype
))
1197 params
[param
] = value
1200 def _get_config_from_yang(self
, config_primitive
, values
):
1201 """Transform the yang config primitive to dict."""
1203 for primitive
in config_primitive
.values():
1204 if primitive
['name'] == 'config':
1205 for parameter
in primitive
['parameter'].values():
1206 param
= str(parameter
['name'])
1207 if parameter
['value'] == "<rw_mgmt_ip>":
1208 config
[param
] = str(values
[parameter
['value']])
1210 config
[param
] = str(parameter
['value'])
1214 def FormatApplicationName(self
, *args
):
1216 Generate a Juju-compatible Application name
1218 :param args tuple: Positional arguments to be used to construct the
1222 - Only accepts characters a-z and non-consequitive dashes (-)
1223 - Application name should not exceed 50 characters
1227 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
1230 for c
in "-".join(list(args
)):
1232 c
= chr(97 + int(c
))
1233 elif not c
.isalpha():
1236 return re
.sub('-+', '-', appname
.lower())
1238 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
1239 # """Format the name of the application
1242 # - Only accepts characters a-z and non-consequitive dashes (-)
1243 # - Application name should not exceed 50 characters
1245 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
1249 # c = chr(97 + int(c))
1250 # elif not c.isalpha():
1253 # return re.sub('\-+', '-', new_name.lower())
1255 def format_model_name(self
, name
):
1256 """Format the name of model.
1258 Model names may only contain lowercase letters, digits and hyphens
1261 return name
.replace('_', '-').lower()
1263 async def get_application(self
, model
, application
):
1264 """Get the deployed application."""
1265 if not self
.authenticated
:
1269 if application
and model
:
1270 if model
.applications
:
1271 if application
in model
.applications
:
1272 app
= model
.applications
[application
]
1276 async def get_model(self
, model_name
):
1277 """Get a model from the Juju Controller.
1279 Note: Model objects returned must call disconnected() before it goes
1281 if not self
.authenticated
:
1284 if model_name
not in self
.models
:
1285 # Get the models in the controller
1286 models
= await self
.controller
.list_models()
1288 if model_name
not in models
:
1290 self
.models
[model_name
] = await self
.controller
.add_model(
1292 config
={'authorized-keys': self
.juju_public_key
}
1295 except JujuError
as e
:
1296 if "already exists" not in e
.message
:
1299 self
.models
[model_name
] = await self
.controller
.get_model(
1303 self
.refcount
['model'] += 1
1305 # Create an observer for this model
1306 await self
.create_model_monitor(model_name
)
1308 return self
.models
[model_name
]
1310 async def create_model_monitor(self
, model_name
):
1311 """Create a monitor for the model, if none exists."""
1312 if not self
.authenticated
:
1315 if model_name
not in self
.monitors
:
1316 self
.monitors
[model_name
] = VCAMonitor(model_name
)
1317 self
.models
[model_name
].add_observer(self
.monitors
[model_name
])
1321 async def login(self
):
1322 """Login to the Juju controller."""
1324 if self
.authenticated
:
1327 self
.connecting
= True
1329 self
.log
.debug("JujuApi: Logging into controller")
1331 self
.controller
= Controller(loop
=self
.loop
)
1335 "Connecting to controller... ws://{} as {}/{}".format(
1342 await self
.controller
.connect(
1343 endpoint
=self
.endpoint
,
1345 password
=self
.secret
,
1346 cacert
=self
.ca_cert
,
1348 self
.refcount
['controller'] += 1
1349 self
.authenticated
= True
1350 self
.log
.debug("JujuApi: Logged into controller")
1351 except Exception as ex
:
1352 self
.log
.debug("Caught exception: {}".format(ex
))
1354 # current_controller no longer exists
1355 # self.log.debug("Connecting to current controller...")
1356 # await self.controller.connect_current()
1357 # await self.controller.connect(
1358 # endpoint=self.endpoint,
1359 # username=self.user,
1362 self
.log
.fatal("VCA credentials not configured.")
1363 self
.authenticated
= False
1366 async def logout(self
):
1367 """Logout of the Juju controller."""
1368 if not self
.authenticated
:
1372 for model
in self
.models
:
1373 await self
.disconnect_model(model
)
1376 self
.log
.debug("Disconnecting controller {}".format(
1379 await self
.controller
.disconnect()
1380 self
.refcount
['controller'] -= 1
1381 self
.controller
= None
1383 self
.authenticated
= False
1385 self
.log
.debug(self
.refcount
)
1387 except Exception as e
:
1389 "Fatal error logging out of Juju Controller: {}".format(e
)
1394 async def disconnect_model(self
, model
):
1395 self
.log
.debug("Disconnecting model {}".format(model
))
1396 if model
in self
.models
:
1398 await self
.models
[model
].disconnect()
1399 self
.refcount
['model'] -= 1
1400 self
.models
[model
] = None
1401 except Exception as e
:
1402 self
.log
.debug("Caught exception: {}".format(e
))
1404 async def provision_machine(self
, model_name
: str,
1405 hostname
: str, username
: str,
1406 private_key_path
: str) -> int:
1407 """Provision a machine.
1409 This executes the SSH provisioner, which will log in to a machine via
1410 SSH and prepare it for use with the Juju model
1412 :param model_name str: The name of the model
1413 :param hostname str: The IP or hostname of the target VM
1414 :param user str: The username to login to
1415 :param private_key_path str: The path to the private key that's been injected to the VM via cloud-init
1416 :return machine_id int: Returns the id of the machine or None if provisioning fails
1418 if not self
.authenticated
:
1424 self
.log
.debug("Instantiating SSH Provisioner for {}@{} ({})".format(
1429 provisioner
= SSHProvisioner(
1432 private_key_path
=private_key_path
,
1438 params
= provisioner
.provision_machine()
1439 except Exception as ex
:
1440 self
.log
.debug("caught exception from provision_machine: {}".format(ex
))
1444 params
.jobs
= ['JobHostUnits']
1446 model
= await self
.get_model(model_name
)
1448 connection
= model
.connection()
1450 # Submit the request.
1451 self
.log
.debug("Adding machine to model")
1452 client_facade
= client
.ClientFacade
.from_connection(connection
)
1453 results
= await client_facade
.AddMachines(params
=[params
])
1454 error
= results
.machines
[0].error
1456 raise ValueError("Error adding machine: %s" % error
.message
)
1458 machine_id
= results
.machines
[0].machine
1460 # Need to run this after AddMachines has been called,
1461 # as we need the machine_id
1462 self
.log
.debug("Installing Juju agent")
1463 await provisioner
.install_agent(
1470 self
.log
.debug("Missing API Proxy")
1473 # async def remove_application(self, name):
1474 # """Remove the application."""
1475 # if not self.authenticated:
1476 # await self.login()
1478 # app = await self.get_application(name)
1480 # self.log.debug("JujuApi: Destroying application {}".format(
1484 # await app.destroy()
1486 async def remove_relation(self
, a
, b
):
1488 Remove a relation between two application endpoints
1490 :param a An application endpoint
1491 :param b An application endpoint
1493 if not self
.authenticated
:
1496 m
= await self
.get_model()
1498 m
.remove_relation(a
, b
)
1500 await m
.disconnect()
1502 async def resolve_error(self
, model_name
, application
=None):
1503 """Resolve units in error state."""
1504 if not self
.authenticated
:
1507 model
= await self
.get_model(model_name
)
1509 app
= await self
.get_application(model
, application
)
1512 "JujuApi: Resolving errors for application {}".format(
1517 for unit
in app
.units
:
1518 app
.resolved(retry
=True)
1520 async def run_action(self
, model_name
, application
, action_name
, **params
):
1521 """Execute an action and return an Action object."""
1522 if not self
.authenticated
:
1532 model
= await self
.get_model(model_name
)
1534 app
= await self
.get_application(model
, application
)
1536 # We currently only have one unit per application
1537 # so use the first unit available.
1541 "JujuApi: Running Action {} against Application {}".format(
1547 action
= await unit
.run_action(action_name
, **params
)
1549 # Wait for the action to complete
1552 result
['status'] = action
.status
1553 result
['action']['tag'] = action
.data
['id']
1554 result
['action']['results'] = action
.results
1558 async def set_config(self
, model_name
, application
, config
):
1559 """Apply a configuration to the application."""
1560 if not self
.authenticated
:
1563 app
= await self
.get_application(model_name
, application
)
1565 self
.log
.debug("JujuApi: Setting config for Application {}".format(
1568 await app
.set_config(config
)
1570 # Verify the config is set
1571 newconf
= await app
.get_config()
1573 if config
[key
] != newconf
[key
]['value']:
1574 self
.log
.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key
, config
[key
], newconf
[key
]))
1576 # async def set_parameter(self, parameter, value, application=None):
1577 # """Set a config parameter for a service."""
1578 # if not self.authenticated:
1579 # await self.login()
1581 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1586 # return await self.apply_config(
1587 # {parameter: value},
1588 # application=application,
1591 async def wait_for_application(self
, model_name
, application_name
,
1593 """Wait for an application to become active."""
1594 if not self
.authenticated
:
1597 model
= await self
.get_model(model_name
)
1599 app
= await self
.get_application(model
, application_name
)
1600 self
.log
.debug("Application: {}".format(app
))
1603 "JujuApi: Waiting {} seconds for Application {}".format(
1609 await model
.block_until(
1611 unit
.agent_status
== 'idle' and unit
.workload_status
in
1612 ['active', 'unknown'] for unit
in app
.units