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.
25 from juju
.client
import client
26 from juju
.controller
import Controller
27 from juju
.errors
import JujuAPIError
, JujuError
28 from juju
.model
import ModelObserver
30 import n2vc
.exceptions
31 from n2vc
.provisioner
import SSHProvisioner
35 # FIXME: this should load the juju inside or modules without having to
36 # explicitly install it. Check why it's not working.
37 # Load our subtree of the juju library
38 # path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
39 # path = os.path.join(path, "modules/libjuju/")
40 # if path not in sys.path:
41 # sys.path.insert(1, path)
42 # We might need this to connect to the websocket securely, but test and verify.
44 ssl
._create
_default
_https
_context
= ssl
._create
_unverified
_context
45 except AttributeError:
46 # Legacy Python doesn't verify by default (see pep-0476)
47 # https://www.python.org/dev/peps/pep-0476/
52 # Deprecated. Please use n2vc.exceptions namespace.
53 class JujuCharmNotFound(Exception):
54 """The Charm can't be found or is not readable."""
57 class JujuApplicationExists(Exception):
58 """The Application already exists."""
61 class N2VCPrimitiveExecutionFailed(Exception):
62 """Something failed while attempting to execute a primitive."""
65 class NetworkServiceDoesNotExist(Exception):
66 """The Network Service being acted against does not exist."""
69 class PrimitiveDoesNotExist(Exception):
70 """The Primitive being executed does not exist."""
73 # Quiet the debug logging
74 logging
.getLogger("websockets.protocol").setLevel(logging
.INFO
)
75 logging
.getLogger("juju.client.connection").setLevel(logging
.WARN
)
76 logging
.getLogger("juju.model").setLevel(logging
.WARN
)
77 logging
.getLogger("juju.machine").setLevel(logging
.WARN
)
80 class VCAMonitor(ModelObserver
):
81 """Monitor state changes within the Juju Model."""
85 def __init__(self
, ns_name
):
86 self
.log
= logging
.getLogger(__name__
)
88 self
.ns_name
= ns_name
89 self
.applications
= {}
91 def AddApplication(self
, application_name
, callback
, *callback_args
):
92 if application_name
not in self
.applications
:
93 self
.applications
[application_name
] = {
95 "callback_args": callback_args
,
98 def RemoveApplication(self
, application_name
):
99 if application_name
in self
.applications
:
100 del self
.applications
[application_name
]
102 async def on_change(self
, delta
, old
, new
, model
):
103 """React to changes in the Juju model."""
105 if delta
.entity
== "unit":
106 # Ignore change events from other applications
107 if delta
.data
["application"] not in self
.applications
.keys():
112 application_name
= delta
.data
["application"]
114 callback
= self
.applications
[application_name
]["callback"]
115 callback_args
= self
.applications
[application_name
]["callback_args"]
118 # Fire off a callback with the application state
122 delta
.data
["application"],
124 new
.workload_status_message
,
129 # This is a charm being removed
133 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.
163 # Create unique models per network service
164 # Document all public functions
177 juju_public_key
=None,
183 Initializes the N2VC object, allowing the caller to interoperate with the VCA.
186 :param log obj: The logging object to log to
187 :param server str: The IP Address or Hostname of the Juju controller
188 :param port int: The port of the Juju Controller
189 :param user str: The Juju username to authenticate with
190 :param secret str: The Juju password to authenticate with
191 :param artifacts str: The directory where charms required by a vnfd are
193 :param loop obj: The loop to use.
194 :param juju_public_key str: The contents of the Juju public SSH key
195 :param ca_cert str: The CA certificate to use to authenticate
196 :param api_proxy str: The IP of the host machine
199 client = n2vc.vnf.N2VC(
205 artifacts='/app/storage/myvnf/charms',
207 juju_public_key='<contents of the juju public key>',
208 ca_cert='<contents of CA certificate>',
209 api_proxy='192.168.1.155'
213 # Initialize instance-level variables
216 self
.controller
= None
217 self
.connecting
= False
218 self
.authenticated
= False
219 self
.api_proxy
= api_proxy
224 self
.log
= logging
.getLogger(__name__
)
243 self
.juju_public_key
= juju_public_key
245 self
._create
_juju
_public
_key
(juju_public_key
)
247 self
.juju_public_key
= ""
249 # TODO: Verify ca_cert is valid before using. VCA will crash
250 # if the ca_cert isn't formatted correctly.
251 def base64_to_cacert(b64string
):
252 """Convert the base64-encoded string containing the VCA CACERT.
258 cacert
= base64
.b64decode(b64string
).decode("utf-8")
260 cacert
= re
.sub(r
"\\n", r
"\n", cacert
,)
261 except binascii
.Error
as e
:
262 self
.log
.debug("Caught binascii.Error: {}".format(e
))
263 raise n2vc
.exceptions
.N2VCInvalidCertificate("Invalid CA Certificate")
269 self
.ca_cert
= base64_to_cacert(ca_cert
)
271 # Quiet websocket traffic
272 logging
.getLogger("websockets.protocol").setLevel(logging
.INFO
)
273 logging
.getLogger("juju.client.connection").setLevel(logging
.WARN
)
274 logging
.getLogger("model").setLevel(logging
.WARN
)
275 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
277 self
.log
.debug("JujuApi: instantiated")
283 if user
.startswith("user-"):
286 self
.user
= "user-{}".format(user
)
288 self
.endpoint
= "%s:%d" % (server
, int(port
))
290 self
.artifacts
= artifacts
292 self
.loop
= loop
or asyncio
.get_event_loop()
295 """Close any open connections."""
298 def _create_juju_public_key(self
, public_key
):
299 """Recreate the Juju public key on disk.
301 Certain libjuju commands expect to be run from the same machine as Juju
302 is bootstrapped to. This method will write the public key to disk in
303 that location: ~/.local/share/juju/ssh/juju_id_rsa.pub
305 # Make sure that we have a public key before writing to disk
306 if public_key
is None or len(public_key
) == 0:
307 if "OSM_VCA_PUBKEY" in os
.environ
:
308 public_key
= os
.getenv("OSM_VCA_PUBKEY", "")
309 if len(public_key
== 0):
314 path
= "{}/.local/share/juju/ssh".format(os
.path
.expanduser("~"),)
315 if not os
.path
.exists(path
):
318 with
open("{}/juju_id_rsa.pub".format(path
), "w") as f
:
333 model_name
, application_name
, status
, message
, *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
345 the name of the endpoint.
351 - provides: dataVM:db
354 This tells N2VC that the charm referred to by the dataVM vdu offers a relation
355 named 'db', and the mgmtVM vdu
356 has an 'app' endpoint that should be connected to a database.
358 :param str ns_name: The name of the network service.
359 :param dict vnfd: The parsed yaml VNF descriptor.
362 # Currently, the call to Relate() is made automatically after the
363 # deployment of each charm; if the relation depends on a charm that
364 # hasn't been deployed yet, the call will fail silently. This will
365 # prevent an API breakage, with the intent of making this an explicitly
366 # required call in a more object-oriented refactor of the N2VC API.
369 vnf_config
= vnfd
.get("vnf-configuration")
371 juju
= vnf_config
["juju"]
373 configs
.append(vnf_config
)
375 for vdu
in vnfd
["vdu"]:
376 vdu_config
= vdu
.get("vdu-configuration")
378 juju
= vdu_config
["juju"]
380 configs
.append(vdu_config
)
382 def _get_application_name(name
):
383 """Get the application name that's mapped to a vnf/vdu."""
385 vnf_name
= vnfd
["name"]
387 for vdu
in vnfd
.get("vdu"):
388 # Compare the named portion of the relation to the vdu's id
389 if vdu
["id"] == name
:
390 application_name
= self
.FormatApplicationName(
391 model_name
, vnf_name
, str(vnf_member_index
),
393 return application_name
395 vnf_member_index
+= 1
399 # Loop through relations
404 "vca-relationships" in juju
405 and "relation" in juju
["vca-relationships"]
407 for rel
in juju
["vca-relationships"]["relation"]:
410 # get the application name for the provides
411 (name
, endpoint
) = rel
["provides"].split(":")
412 application_name
= _get_application_name(name
)
414 provides
= "{}:{}".format(application_name
, endpoint
)
416 # get the application name for thr requires
417 (name
, endpoint
) = rel
["requires"].split(":")
418 application_name
= _get_application_name(name
)
420 requires
= "{}:{}".format(application_name
, endpoint
)
422 "Relation: {} <-> {}".format(provides
, requires
)
424 await self
.add_relation(
425 model_name
, provides
, requires
,
427 except Exception as e
:
428 self
.log
.debug("Exception: {}".format(e
))
432 async def DeployCharms(
443 """Deploy one or more charms associated with a VNF.
445 Deploy the charm(s) referenced in a VNF Descriptor.
447 :param str model_name: The name or unique id of the network service.
448 :param str application_name: The name of the application
449 :param dict vnfd: The name of the application
450 :param str charm_path: The path to the Juju charm
451 :param dict params: A dictionary of runtime parameters
454 'rw_mgmt_ip': '1.2.3.4',
455 # Pass the initial-config-primitives section of the vnf or vdu
456 'initial-config-primitives': {...}
457 'user_values': dictionary with the day-1 parameters provided at
458 instantiation time. It will replace values
459 inside < >. rw_mgmt_ip will be included here also
461 :param dict machine_spec: A dictionary describing the machine to
465 'hostname': '1.2.3.4',
466 'username': 'ubuntu',
468 :param obj callback: A callback function to receive status changes.
469 :param tuple callback_args: A list of arguments to be passed to the
473 ########################################################
474 # Verify the path to the charm exists and is readable. #
475 ########################################################
476 if not os
.path
.exists(charm_path
):
477 self
.log
.debug("Charm path doesn't exist: {}".format(charm_path
))
478 self
.notify_callback(
486 raise JujuCharmNotFound("No artifacts configured.")
488 ################################
489 # Login to the Juju controller #
490 ################################
491 if not self
.authenticated
:
492 self
.log
.debug("Authenticating with Juju")
495 ##########################################
496 # Get the model for this network service #
497 ##########################################
498 model
= await self
.get_model(model_name
)
500 ########################################
501 # Verify the application doesn't exist #
502 ########################################
503 app
= await self
.get_application(model
, application_name
)
505 raise JujuApplicationExists(
507 'Can\'t deploy application "{}" to model '
508 ' "{}" because it already exists.'
509 ).format(application_name
, model_name
)
512 ################################################################
513 # Register this application with the model-level event monitor #
514 ################################################################
517 "JujuApi: Registering callback for {}".format(application_name
,)
519 await self
.Subscribe(model_name
, application_name
, callback
, *callback_args
)
521 #######################################
522 # Get the initial charm configuration #
523 #######################################
526 if "rw_mgmt_ip" in params
:
527 rw_mgmt_ip
= params
["rw_mgmt_ip"]
529 if "initial-config-primitive" not in params
:
530 params
["initial-config-primitive"] = {}
532 initial_config
= self
._get
_config
_from
_dict
(
533 params
["initial-config-primitive"], {"<rw_mgmt_ip>": rw_mgmt_ip
}
536 ########################################################
537 # Check for specific machine placement (native charms) #
538 ########################################################
542 if machine_spec
.keys():
543 if all(k
in machine_spec
for k
in ["hostname", "username"]):
545 # Allow series to be derived from the native charm
549 "Provisioning manual machine {}@{}".format(
550 machine_spec
["username"], machine_spec
["hostname"],
554 """Native Charm support
556 Taking a bare VM (assumed to be an Ubuntu cloud image),
557 the provisioning process will:
558 - Create an ubuntu user w/sudo access
560 - Detect architecture
561 - Download and install Juju agent from controller
563 - Add an iptables rule to route traffic to the API proxy
566 to
= await self
.provision_machine(
567 model_name
=model_name
,
568 username
=machine_spec
["username"],
569 hostname
=machine_spec
["hostname"],
570 private_key_path
=self
.GetPrivateKeyPath(),
572 self
.log
.debug("Provisioned machine id {}".format(to
))
574 # TODO: If to is none, raise an exception
576 # The native charm won't have the sshproxy layer, typically, but LCM
577 # uses the config primitive
578 # to interpret what the values are. That's a gap to fill.
581 The ssh-* config parameters are unique to the sshproxy layer,
582 which most native charms will not be aware of.
584 Setting invalid config parameters will cause the deployment to
587 For the moment, we will strip the ssh-* parameters from native
588 charms, until the feature gap is addressed in the information
592 # Native charms don't include the ssh-* config values, so strip them
593 # from the initial_config, otherwise the deploy will raise an error.
594 # self.log.debug("Removing ssh-* from initial-config")
595 for k
in ["ssh-hostname", "ssh-username", "ssh-password"]:
596 if k
in initial_config
:
597 self
.log
.debug("Removing parameter {}".format(k
))
598 del initial_config
[k
]
601 "JujuApi: Deploying charm ({}/{}) from {} to {}".format(
602 model_name
, application_name
, charm_path
, to
,
606 ########################################################
607 # Deploy the charm and apply the initial configuration #
608 ########################################################
609 app
= await model
.deploy(
610 # We expect charm_path to be either the path to the charm on disk
611 # or in the format of cs:series/name
613 # This is the formatted, unique name for this charm
614 application_name
=application_name
,
615 # Proxy charms should use the current LTS. This will need to be
616 # changed for native charms.
618 # Apply the initial 'config' primitive during deployment
619 config
=initial_config
,
620 # Where to deploy the charm to.
624 #############################
625 # Map the vdu id<->app name #
626 #############################
628 await self
.Relate(model_name
, vnfd
)
629 except KeyError as ex
:
630 # We don't currently support relations between NS and VNF/VDU charms
631 self
.log
.warn("[N2VC] Relations not supported: {}".format(ex
))
633 # This may happen if not all of the charms needed by the relation
634 # are ready. We can safely ignore this, because Relate will be
635 # retried when the endpoint of the relation is deployed.
636 self
.log
.warn("[N2VC] Relations not ready")
638 # #######################################
639 # # Execute initial config primitive(s) #
640 # #######################################
641 uuids
= await self
.ExecuteInitialPrimitives(
642 model_name
, application_name
, params
,
648 # # Build a sequential list of the primitives to execute
649 # for primitive in params['initial-config-primitive']:
651 # if primitive['name'] == 'config':
652 # # This is applied when the Application is deployed
655 # seq = primitive['seq']
658 # if 'parameter' in primitive:
659 # params = primitive['parameter']
661 # primitives[seq] = {
662 # 'name': primitive['name'],
663 # 'parameters': self._map_primitive_parameters(
665 # {'<rw_mgmt_ip>': rw_mgmt_ip}
669 # for primitive in sorted(primitives):
670 # await self.ExecutePrimitive(
673 # primitives[primitive]['name'],
676 # **primitives[primitive]['parameters'],
678 # except N2VCPrimitiveExecutionFailed as e:
680 # "[N2VC] Exception executing primitive: {}".format(e)
684 async def GetPrimitiveStatus(self
, model_name
, uuid
):
685 """Get the status of an executed Primitive.
687 The status of an executed Primitive will be one of three values:
694 if not self
.authenticated
:
697 model
= await self
.get_model(model_name
)
699 results
= await model
.get_action_status(uuid
)
702 status
= results
[uuid
]
704 except Exception as e
:
706 "Caught exception while getting primitive status: {}".format(e
)
708 raise N2VCPrimitiveExecutionFailed(e
)
712 async def GetPrimitiveOutput(self
, model_name
, uuid
):
713 """Get the output of an executed Primitive.
715 Note: this only returns output for a successfully executed primitive.
719 if not self
.authenticated
:
722 model
= await self
.get_model(model_name
)
723 results
= await model
.get_action_output(uuid
, 60)
724 except Exception as e
:
726 "Caught exception while getting primitive status: {}".format(e
)
728 raise N2VCPrimitiveExecutionFailed(e
)
732 # async def ProvisionMachine(self, model_name, hostname, username):
733 # """Provision machine for usage with Juju.
735 # Provisions a previously instantiated machine for use with Juju.
738 # if not self.authenticated:
741 # # FIXME: This is hard-coded until model-per-ns is added
742 # model_name = 'default'
744 # model = await self.get_model(model_name)
745 # model.add_machine(spec={})
747 # machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
754 # except Exception as e:
756 # "Caught exception while getting primitive status: {}".format(e)
758 # raise N2VCPrimitiveExecutionFailed(e)
760 def GetPrivateKeyPath(self
):
761 homedir
= os
.environ
["HOME"]
762 sshdir
= "{}/.ssh".format(homedir
)
763 private_key_path
= "{}/id_n2vc_rsa".format(sshdir
)
764 return private_key_path
766 async def GetPublicKey(self
):
767 """Get the N2VC SSH public key.abs
769 Returns the SSH public key, to be injected into virtual machines to
770 be managed by the VCA.
772 The first time this is run, a ssh keypair will be created. The public
773 key is injected into a VM so that we can provision the machine with
774 Juju, after which Juju will communicate with the VM directly via the
779 # Find the path to where we expect our key to live.
780 homedir
= os
.environ
["HOME"]
781 sshdir
= "{}/.ssh".format(homedir
)
782 if not os
.path
.exists(sshdir
):
785 private_key_path
= "{}/id_n2vc_rsa".format(sshdir
)
786 public_key_path
= "{}.pub".format(private_key_path
)
788 # If we don't have a key generated, generate it.
789 if not os
.path
.exists(private_key_path
):
790 cmd
= "ssh-keygen -t {} -b {} -N '' -f {}".format(
791 "rsa", "4096", private_key_path
793 subprocess
.check_output(shlex
.split(cmd
))
795 # Read the public key
796 with
open(public_key_path
, "r") as f
:
797 public_key
= f
.readline()
801 async def ExecuteInitialPrimitives(
802 self
, model_name
, application_name
, params
, callback
=None, *callback_args
804 """Execute multiple primitives.
806 Execute multiple primitives as declared in initial-config-primitive.
807 This is useful in cases where the primitives initially failed -- for
808 example, if the charm is a proxy but the proxy hasn't been configured
814 # Build a sequential list of the primitives to execute
815 for primitive
in params
["initial-config-primitive"]:
817 if primitive
["name"] == "config":
820 seq
= primitive
["seq"]
823 if "parameter" in primitive
:
824 params_
= primitive
["parameter"]
826 user_values
= params
.get("user_values", {})
827 if "rw_mgmt_ip" not in user_values
:
828 user_values
["rw_mgmt_ip"] = None
829 # just for backward compatibility, because it will be provided
830 # always by modern version of LCM
833 "name": primitive
["name"],
834 "parameters": self
._map
_primitive
_parameters
(
839 for primitive
in sorted(primitives
):
841 # self.log.debug("Queuing action {}".format(
842 # primitives[primitive]['name']))
844 await self
.ExecutePrimitive(
847 primitives
[primitive
]["name"],
850 **primitives
[primitive
]["parameters"],
853 except PrimitiveDoesNotExist
as e
:
855 "Ignoring exception PrimitiveDoesNotExist: {}".format(e
)
858 except Exception as e
:
861 "XXXXXXXXXXXXXXXXXXXXXXXXX Unexpected exception: {}"
866 except N2VCPrimitiveExecutionFailed
as e
:
867 self
.log
.debug("[N2VC] Exception executing primitive: {}".format(e
))
871 async def ExecutePrimitive(
880 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
882 Execute a primitive defined in the VNF descriptor.
884 :param str model_name: The name or unique id of the network service.
885 :param str application_name: The name of the application
886 :param str primitive: The name of the primitive to execute.
887 :param obj callback: A callback function to receive status changes.
888 :param tuple callback_args: A list of arguments to be passed to the
890 :param dict params: A dictionary of key=value pairs representing the
891 primitive's parameters
894 'rw_mgmt_ip': '1.2.3.4',
895 # Pass the initial-config-primitives section of the vnf or vdu
896 'initial-config-primitives': {...}
899 self
.log
.debug("Executing primitive={} params={}".format(primitive
, params
))
902 if not self
.authenticated
:
905 model
= await self
.get_model(model_name
)
907 if primitive
== "config":
908 # config is special, and expecting params to be a dictionary
909 await self
.set_config(
910 model
, application_name
, params
["params"],
913 app
= await self
.get_application(model
, application_name
)
915 # Does this primitive exist?
916 actions
= await app
.get_actions()
918 if primitive
not in actions
.keys():
919 raise PrimitiveDoesNotExist(
920 "Primitive {} does not exist".format(primitive
)
923 # Run against the first (and probably only) unit in the app
926 action
= await unit
.run_action(primitive
, **params
)
928 except PrimitiveDoesNotExist
as e
:
929 # Catch and raise this exception if it's thrown from the inner block
931 except Exception as e
:
932 # An unexpected exception was caught
933 self
.log
.debug("Caught exception while executing primitive: {}".format(e
))
934 raise N2VCPrimitiveExecutionFailed(e
)
937 async def RemoveCharms(
938 self
, model_name
, application_name
, callback
=None, *callback_args
940 """Remove a charm from the VCA.
942 Remove a charm referenced in a VNF Descriptor.
944 :param str model_name: The name of the network service.
945 :param str application_name: The name of the application
946 :param obj callback: A callback function to receive status changes.
947 :param tuple callback_args: A list of arguments to be passed to the
951 if not self
.authenticated
:
954 model
= await self
.get_model(model_name
)
955 app
= await self
.get_application(model
, application_name
)
957 # Remove this application from event monitoring
958 await self
.Unsubscribe(model_name
, application_name
)
960 # self.notify_callback(model_name, application_name, "removing",
961 # callback, *callback_args)
962 self
.log
.debug("Removing the application {}".format(application_name
))
965 # await self.disconnect_model(self.monitors[model_name])
967 self
.notify_callback(
971 "Removing charm {}".format(application_name
),
976 except Exception as e
:
977 print("Caught exception: {}".format(e
))
981 async def CreateNetworkService(self
, ns_uuid
):
982 """Create a new Juju model for the Network Service.
984 Creates a new Model in the Juju Controller.
986 :param str ns_uuid: A unique id representing an instaance of a
989 :returns: True if the model was created. Raises JujuError on failure.
991 if not self
.authenticated
:
994 models
= await self
.controller
.list_models()
995 if ns_uuid
not in models
:
997 await self
.get_model(ns_uuid
)
1001 async def DestroyNetworkService(self
, ns_uuid
):
1002 """Destroy a Network Service.
1004 Destroy the Network Service and any deployed charms.
1006 :param ns_uuid The unique id of the Network Service
1008 :returns: True if the model was created. Raises JujuError on failure.
1011 # Do not delete the default model. The default model was used by all
1012 # Network Services, prior to the implementation of a model per NS.
1013 if ns_uuid
.lower() == "default":
1016 if not self
.authenticated
:
1019 models
= await self
.controller
.list_models()
1020 if ns_uuid
in models
:
1021 model
= await self
.controller
.get_model(ns_uuid
)
1023 for application
in model
.applications
:
1024 app
= model
.applications
[application
]
1026 await self
.RemoveCharms(ns_uuid
, application
)
1028 self
.log
.debug("Unsubscribing Watcher for {}".format(application
))
1029 await self
.Unsubscribe(ns_uuid
, application
)
1031 self
.log
.debug("Waiting for application to terminate")
1034 await model
.block_until(
1036 unit
.workload_status
in ["terminated"] for unit
in app
.units
1042 "Timed out waiting for {} to terminate.".format(application
)
1045 for machine
in model
.machines
:
1047 self
.log
.debug("Destroying machine {}".format(machine
))
1048 await model
.machines
[machine
].destroy(force
=True)
1049 except JujuAPIError
as e
:
1050 if "does not exist" in str(e
):
1051 # Our cached model may be stale, because the machine
1052 # has already been removed. It's safe to continue.
1055 self
.log
.debug("Caught exception: {}".format(e
))
1058 # Disconnect from the Model
1059 if ns_uuid
in self
.models
:
1060 self
.log
.debug("Disconnecting model {}".format(ns_uuid
))
1061 # await self.disconnect_model(self.models[ns_uuid])
1062 await self
.disconnect_model(ns_uuid
)
1065 self
.log
.debug("Destroying model {}".format(ns_uuid
))
1066 await self
.controller
.destroy_models(ns_uuid
)
1068 raise NetworkServiceDoesNotExist(
1069 "The Network Service '{}' does not exist".format(ns_uuid
)
1074 async def GetMetrics(self
, model_name
, application_name
):
1075 """Get the metrics collected by the VCA.
1077 :param model_name The name or unique id of the network service
1078 :param application_name The name of the application
1081 model
= await self
.get_model(model_name
)
1082 app
= await self
.get_application(model
, application_name
)
1084 metrics
= await app
.get_metrics()
1088 async def HasApplication(self
, model_name
, application_name
):
1089 model
= await self
.get_model(model_name
)
1090 app
= await self
.get_application(model
, application_name
)
1095 async def Subscribe(self
, ns_name
, application_name
, callback
, *callback_args
):
1096 """Subscribe to callbacks for an application.
1098 :param ns_name str: The name of the Network Service
1099 :param application_name str: The name of the application
1100 :param callback obj: The callback method
1101 :param callback_args list: The list of arguments to append to calls to
1104 self
.monitors
[ns_name
].AddApplication(
1105 application_name
, callback
, *callback_args
1108 async def Unsubscribe(self
, ns_name
, application_name
):
1109 """Unsubscribe to callbacks for an application.
1111 Unsubscribes the caller from notifications from a deployed application.
1113 :param ns_name str: The name of the Network Service
1114 :param application_name str: The name of the application
1116 self
.monitors
[ns_name
].RemoveApplication(application_name
,)
1118 # Non-public methods
1119 async def add_relation(self
, model_name
, relation1
, relation2
):
1121 Add a relation between two application endpoints.
1123 :param str model_name: The name or unique id of the network service
1124 :param str relation1: '<application>[:<relation_name>]'
1125 :param str relation2: '<application>[:<relation_name>]'
1128 if not self
.authenticated
:
1131 m
= await self
.get_model(model_name
)
1133 await m
.add_relation(relation1
, relation2
)
1134 except JujuAPIError
as e
:
1135 # If one of the applications in the relationship doesn't exist,
1136 # or the relation has already been added, let the operation fail
1138 if "not found" in e
.message
:
1140 if "already exists" in e
.message
:
1145 # async def apply_config(self, config, application):
1146 # """Apply a configuration to the application."""
1147 # print("JujuApi: Applying configuration to {}.".format(
1150 # return await self.set_config(application=application, config=config)
1152 def _get_config_from_dict(self
, config_primitive
, values
):
1153 """Transform the yang config primitive to dict.
1162 for primitive
in config_primitive
:
1163 if primitive
["name"] == "config":
1164 # config = self._map_primitive_parameters()
1165 for parameter
in primitive
["parameter"]:
1166 param
= str(parameter
["name"])
1167 if parameter
["value"] == "<rw_mgmt_ip>":
1168 config
[param
] = str(values
[parameter
["value"]])
1170 config
[param
] = str(parameter
["value"])
1174 def _map_primitive_parameters(self
, parameters
, user_values
):
1176 for parameter
in parameters
:
1177 param
= str(parameter
["name"])
1178 value
= parameter
.get("value")
1180 # map parameters inside a < >; e.g. <rw_mgmt_ip>. with the provided user
1182 # Must exist at user_values except if there is a default value
1183 if isinstance(value
, str) and value
.startswith("<") and value
.endswith(">"):
1184 if parameter
["value"][1:-1] in user_values
:
1185 value
= user_values
[parameter
["value"][1:-1]]
1186 elif "default-value" in parameter
:
1187 value
= parameter
["default-value"]
1190 "parameter {}='{}' not supplied ".format(param
, value
)
1193 # If there's no value, use the default-value (if set)
1194 if value
is None and "default-value" in parameter
:
1195 value
= parameter
["default-value"]
1197 # Typecast parameter value, if present
1198 paramtype
= "string"
1200 if "data-type" in parameter
:
1201 paramtype
= str(parameter
["data-type"]).lower()
1203 if paramtype
== "integer":
1205 elif paramtype
== "boolean":
1210 # If there's no data-type, assume the value is a string
1214 "parameter {}='{}' cannot be converted to type {}".format(
1215 param
, value
, paramtype
1219 params
[param
] = value
1222 def _get_config_from_yang(self
, config_primitive
, values
):
1223 """Transform the yang config primitive to dict."""
1225 for primitive
in config_primitive
.values():
1226 if primitive
["name"] == "config":
1227 for parameter
in primitive
["parameter"].values():
1228 param
= str(parameter
["name"])
1229 if parameter
["value"] == "<rw_mgmt_ip>":
1230 config
[param
] = str(values
[parameter
["value"]])
1232 config
[param
] = str(parameter
["value"])
1236 def FormatApplicationName(self
, *args
):
1238 Generate a Juju-compatible Application name
1240 :param args tuple: Positional arguments to be used to construct the
1244 - Only accepts characters a-z and non-consequitive dashes (-)
1245 - Application name should not exceed 50 characters
1249 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
1252 for c
in "-".join(list(args
)):
1254 c
= chr(97 + int(c
))
1255 elif not c
.isalpha():
1258 return re
.sub("-+", "-", appname
.lower())
1260 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
1261 # """Format the name of the application
1264 # - Only accepts characters a-z and non-consequitive dashes (-)
1265 # - Application name should not exceed 50 characters
1267 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
1271 # c = chr(97 + int(c))
1272 # elif not c.isalpha():
1275 # return re.sub('\-+', '-', new_name.lower())
1277 def format_model_name(self
, name
):
1278 """Format the name of model.
1280 Model names may only contain lowercase letters, digits and hyphens
1283 return name
.replace("_", "-").lower()
1285 async def get_application(self
, model
, application
):
1286 """Get the deployed application."""
1287 if not self
.authenticated
:
1291 if application
and model
:
1292 if model
.applications
:
1293 if application
in model
.applications
:
1294 app
= model
.applications
[application
]
1298 async def get_model(self
, model_name
):
1299 """Get a model from the Juju Controller.
1301 Note: Model objects returned must call disconnected() before it goes
1303 if not self
.authenticated
:
1306 if model_name
not in self
.models
:
1307 # Get the models in the controller
1308 models
= await self
.controller
.list_models()
1310 if model_name
not in models
:
1312 self
.models
[model_name
] = await self
.controller
.add_model(
1313 model_name
, config
={"authorized-keys": self
.juju_public_key
}
1315 except JujuError
as e
:
1316 if "already exists" not in e
.message
:
1319 self
.models
[model_name
] = await self
.controller
.get_model(model_name
)
1321 self
.refcount
["model"] += 1
1323 # Create an observer for this model
1324 await self
.create_model_monitor(model_name
)
1326 return self
.models
[model_name
]
1328 async def create_model_monitor(self
, model_name
):
1329 """Create a monitor for the model, if none exists."""
1330 if not self
.authenticated
:
1333 if model_name
not in self
.monitors
:
1334 self
.monitors
[model_name
] = VCAMonitor(model_name
)
1335 self
.models
[model_name
].add_observer(self
.monitors
[model_name
])
1339 async def login(self
):
1340 """Login to the Juju controller."""
1342 if self
.authenticated
:
1345 self
.connecting
= True
1347 self
.log
.debug("JujuApi: Logging into controller")
1349 self
.controller
= Controller(loop
=self
.loop
)
1353 "Connecting to controller... ws://{} as {}/{}".format(
1354 self
.endpoint
, self
.user
, self
.secret
,
1358 await self
.controller
.connect(
1359 endpoint
=self
.endpoint
,
1361 password
=self
.secret
,
1362 cacert
=self
.ca_cert
,
1364 self
.refcount
["controller"] += 1
1365 self
.authenticated
= True
1366 self
.log
.debug("JujuApi: Logged into controller")
1367 except Exception as ex
:
1368 self
.log
.debug("Caught exception: {}".format(ex
))
1370 # current_controller no longer exists
1371 # self.log.debug("Connecting to current controller...")
1372 # await self.controller.connect_current()
1373 # await self.controller.connect(
1374 # endpoint=self.endpoint,
1375 # username=self.user,
1378 self
.log
.fatal("VCA credentials not configured.")
1379 self
.authenticated
= False
1381 async def logout(self
):
1382 """Logout of the Juju controller."""
1383 if not self
.authenticated
:
1387 for model
in self
.models
:
1388 await self
.disconnect_model(model
)
1391 self
.log
.debug("Disconnecting controller {}".format(self
.controller
))
1392 await self
.controller
.disconnect()
1393 self
.refcount
["controller"] -= 1
1394 self
.controller
= None
1396 self
.authenticated
= False
1398 self
.log
.debug(self
.refcount
)
1400 except Exception as e
:
1401 self
.log
.fatal("Fatal error logging out of Juju Controller: {}".format(e
))
1405 async def disconnect_model(self
, model
):
1406 self
.log
.debug("Disconnecting model {}".format(model
))
1407 if model
in self
.models
:
1409 await self
.models
[model
].disconnect()
1410 self
.refcount
["model"] -= 1
1411 self
.models
[model
] = None
1412 except Exception as e
:
1413 self
.log
.debug("Caught exception: {}".format(e
))
1415 async def provision_machine(
1416 self
, model_name
: str, hostname
: str, username
: str, private_key_path
: str
1418 """Provision a machine.
1420 This executes the SSH provisioner, which will log in to a machine via
1421 SSH and prepare it for use with the Juju model
1423 :param model_name str: The name of the model
1424 :param hostname str: The IP or hostname of the target VM
1425 :param user str: The username to login to
1426 :param private_key_path str: The path to the private key that's been injected
1427 to the VM via cloud-init
1428 :return machine_id int: Returns the id of the machine or None if provisioning
1431 if not self
.authenticated
:
1438 "Instantiating SSH Provisioner for {}@{} ({})".format(
1439 username
, hostname
, private_key_path
1442 provisioner
= SSHProvisioner(
1445 private_key_path
=private_key_path
,
1451 params
= provisioner
.provision_machine()
1452 except Exception as ex
:
1453 self
.log
.debug("caught exception from provision_machine: {}".format(ex
))
1457 params
.jobs
= ["JobHostUnits"]
1459 model
= await self
.get_model(model_name
)
1461 connection
= model
.connection()
1463 # Submit the request.
1464 self
.log
.debug("Adding machine to model")
1465 client_facade
= client
.ClientFacade
.from_connection(connection
)
1466 results
= await client_facade
.AddMachines(params
=[params
])
1467 error
= results
.machines
[0].error
1469 raise ValueError("Error adding machine: %s" % error
.message
)
1471 machine_id
= results
.machines
[0].machine
1473 # Need to run this after AddMachines has been called,
1474 # as we need the machine_id
1475 self
.log
.debug("Installing Juju agent")
1476 await provisioner
.install_agent(
1477 connection
, params
.nonce
, machine_id
, self
.api_proxy
,
1480 self
.log
.debug("Missing API Proxy")
1483 # async def remove_application(self, name):
1484 # """Remove the application."""
1485 # if not self.authenticated:
1486 # await self.login()
1488 # app = await self.get_application(name)
1490 # self.log.debug("JujuApi: Destroying application {}".format(
1494 # await app.destroy()
1496 async def remove_relation(self
, a
, b
):
1498 Remove a relation between two application endpoints
1500 :param a An application endpoint
1501 :param b An application endpoint
1503 if not self
.authenticated
:
1506 # m = await self.get_model()
1508 # m.remove_relation(a, b)
1510 # await m.disconnect()
1512 async def resolve_error(self
, model_name
, application
=None):
1513 """Resolve units in error state."""
1514 if not self
.authenticated
:
1517 model
= await self
.get_model(model_name
)
1519 app
= await self
.get_application(model
, application
)
1522 "JujuApi: Resolving errors for application {}".format(application
,)
1526 app
.resolved(retry
=True)
1528 async def run_action(self
, model_name
, application
, action_name
, **params
):
1529 """Execute an action and return an Action object."""
1530 if not self
.authenticated
:
1532 result
= {"status": "", "action": {"tag": None, "results": None}}
1534 model
= await self
.get_model(model_name
)
1536 app
= await self
.get_application(model
, application
)
1538 # We currently only have one unit per application
1539 # so use the first unit available.
1543 "JujuApi: Running Action {} against Application {}".format(
1544 action_name
, application
,
1548 action
= await unit
.run_action(action_name
, **params
)
1550 # Wait for the action to complete
1553 result
["status"] = action
.status
1554 result
["action"]["tag"] = action
.data
["id"]
1555 result
["action"]["results"] = action
.results
1559 async def set_config(self
, model_name
, application
, config
):
1560 """Apply a configuration to the application."""
1561 if not self
.authenticated
:
1564 app
= await self
.get_application(model_name
, application
)
1567 "JujuApi: Setting config for Application {}".format(application
,)
1569 await app
.set_config(config
)
1571 # Verify the config is set
1572 newconf
= await app
.get_config()
1574 if config
[key
] != newconf
[key
]["value"]:
1577 "JujuApi: Config not set! Key {} Value {} doesn't match {}"
1578 ).format(key
, config
[key
], newconf
[key
])
1581 # async def set_parameter(self, parameter, value, application=None):
1582 # """Set a config parameter for a service."""
1583 # if not self.authenticated:
1584 # await self.login()
1586 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1591 # return await self.apply_config(
1592 # {parameter: value},
1593 # application=application,
1596 async def wait_for_application(self
, model_name
, application_name
, timeout
=300):
1597 """Wait for an application to become active."""
1598 if not self
.authenticated
:
1601 model
= await self
.get_model(model_name
)
1603 app
= await self
.get_application(model
, application_name
)
1604 self
.log
.debug("Application: {}".format(app
))
1607 "JujuApi: Waiting {} seconds for Application {}".format(
1608 timeout
, application_name
,
1612 await model
.block_until(
1614 unit
.agent_status
== "idle"
1615 and unit
.workload_status
in ["active", "unknown"]
1616 for unit
in app
.units