12 # FIXME: this should load the juju inside or modules without having to
13 # explicitly install it. Check why it's not working.
14 # Load our subtree of the juju library
15 path
= os
.path
.abspath(os
.path
.join(os
.path
.dirname(__file__
), '..'))
16 path
= os
.path
.join(path
, "modules/libjuju/")
17 if path
not in sys
.path
:
18 sys
.path
.insert(1, path
)
20 from juju
.controller
import Controller
21 from juju
.model
import ModelObserver
24 # We might need this to connect to the websocket securely, but test and verify.
26 ssl
._create
_default
_https
_context
= ssl
._create
_unverified
_context
27 except AttributeError:
28 # Legacy Python doesn't verify by default (see pep-0476)
29 # https://www.python.org/dev/peps/pep-0476/
34 class JujuCharmNotFound(Exception):
35 """The Charm can't be found or is not readable."""
38 class JujuApplicationExists(Exception):
39 """The Application already exists."""
42 class N2VCPrimitiveExecutionFailed(Exception):
43 """Something failed while attempting to execute a primitive."""
46 # Quiet the debug logging
47 logging
.getLogger('websockets.protocol').setLevel(logging
.INFO
)
48 logging
.getLogger('juju.client.connection').setLevel(logging
.WARN
)
49 logging
.getLogger('juju.model').setLevel(logging
.WARN
)
50 logging
.getLogger('juju.machine').setLevel(logging
.WARN
)
53 class VCAMonitor(ModelObserver
):
54 """Monitor state changes within the Juju Model."""
59 def __init__(self
, ns_name
):
60 self
.log
= logging
.getLogger(__name__
)
62 self
.ns_name
= ns_name
64 def AddApplication(self
, application_name
, callback
, *callback_args
):
65 if application_name
not in self
.applications
:
66 self
.applications
[application_name
] = {
68 'callback_args': callback_args
71 def RemoveApplication(self
, application_name
):
72 if application_name
in self
.applications
:
73 del self
.applications
[application_name
]
75 async def on_change(self
, delta
, old
, new
, model
):
76 """React to changes in the Juju model."""
78 if delta
.entity
== "unit":
79 # Ignore change events from other applications
80 if delta
.data
['application'] not in self
.applications
.keys():
85 application_name
= delta
.data
['application']
87 callback
= self
.applications
[application_name
]['callback']
89 self
.applications
[application_name
]['callback_args']
92 # Fire off a callback with the application state
96 delta
.data
['application'],
98 new
.workload_status_message
,
102 # This is a charm being removed
106 delta
.data
['application'],
110 except Exception as e
:
111 self
.log
.debug("[1] notify_callback exception: {}".format(e
))
113 elif delta
.entity
== "action":
114 # TODO: Decide how we want to notify the user of actions
116 # uuid = delta.data['id'] # The Action's unique id
117 # msg = delta.data['message'] # The output of the action
119 # if delta.data['status'] == "pending":
120 # # The action is queued
122 # elif delta.data['status'] == "completed""
123 # # The action was successful
125 # elif delta.data['status'] == "failed":
126 # # The action failed.
134 # Create unique models per network service
135 # Document all public functions
150 :param vcaconfig dict A dictionary containing the VCA configuration
152 :param artifacts str The directory where charms required by a vnfd are
156 n2vc = N2VC(vcaconfig={
157 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm',
159 'ip-address': '10.44.127.137',
161 'artifacts': '/path/to/charms'
165 # Initialize instance-level variables
168 self
.controller
= None
169 self
.connecting
= False
170 self
.authenticated
= False
179 self
.default_model
= None
193 self
.log
= logging
.getLogger(__name__
)
195 # Quiet websocket traffic
196 logging
.getLogger('websockets.protocol').setLevel(logging
.INFO
)
197 logging
.getLogger('juju.client.connection').setLevel(logging
.WARN
)
198 logging
.getLogger('model').setLevel(logging
.WARN
)
199 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
201 self
.log
.debug('JujuApi: instantiated')
207 if user
.startswith('user-'):
210 self
.user
= 'user-{}'.format(user
)
212 self
.endpoint
= '%s:%d' % (server
, int(port
))
214 self
.artifacts
= artifacts
216 self
.loop
= loop
or asyncio
.get_event_loop()
219 """Close any open connections."""
222 def notify_callback(self
, model_name
, application_name
, status
, message
,
223 callback
=None, *callback_args
):
232 except Exception as e
:
233 self
.log
.error("[0] notify_callback exception {}".format(e
))
238 async def CreateNetworkService(self
, nsd
):
239 """Create a new model to encapsulate this network service.
241 Create a new model in the Juju controller to encapsulate the
242 charms associated with a network service.
244 You can pass either the nsd record or the id of the network
245 service, but this method will fail without one of them.
247 if not self
.authenticated
:
250 # Ideally, we will create a unique model per network service.
251 # This change will require all components, i.e., LCM and SO, to use
252 # N2VC for 100% compatibility. If we adopt unique models for the LCM,
253 # services deployed via LCM would't be manageable via SO and vice versa
255 return self
.default_model
257 async def DeployCharms(self
, model_name
, application_name
, vnfd
,
258 charm_path
, params
={}, machine_spec
={},
259 callback
=None, *callback_args
):
260 """Deploy one or more charms associated with a VNF.
262 Deploy the charm(s) referenced in a VNF Descriptor.
264 :param str model_name: The name of the network service.
265 :param str application_name: The name of the application
266 :param dict vnfd: The name of the application
267 :param str charm_path: The path to the Juju charm
268 :param dict params: A dictionary of runtime parameters
271 'rw_mgmt_ip': '1.2.3.4',
272 # Pass the initial-config-primitives section of the vnf or vdu
273 'initial-config-primitives': {...}
275 :param dict machine_spec: A dictionary describing the machine to
279 'hostname': '1.2.3.4',
280 'username': 'ubuntu',
282 :param obj callback: A callback function to receive status changes.
283 :param tuple callback_args: A list of arguments to be passed to the
287 ########################################################
288 # Verify the path to the charm exists and is readable. #
289 ########################################################
290 if not os
.path
.exists(charm_path
):
291 self
.log
.debug("Charm path doesn't exist: {}".format(charm_path
))
292 self
.notify_callback(
299 raise JujuCharmNotFound("No artifacts configured.")
301 ################################
302 # Login to the Juju controller #
303 ################################
304 if not self
.authenticated
:
305 self
.log
.debug("Authenticating with Juju")
308 ##########################################
309 # Get the model for this network service #
310 ##########################################
311 # TODO: In a point release, we will use a model per deployed network
312 # service. In the meantime, we will always use the 'default' model.
313 model_name
= 'default'
314 model
= await self
.get_model(model_name
)
316 ########################################
317 # Verify the application doesn't exist #
318 ########################################
319 app
= await self
.get_application(model
, application_name
)
321 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name
, model_name
))
323 ################################################################
324 # Register this application with the model-level event monitor #
325 ################################################################
327 self
.monitors
[model_name
].AddApplication(
333 ########################################################
334 # Check for specific machine placement (native charms) #
335 ########################################################
337 if machine_spec
.keys():
338 if all(k
in machine_spec
for k
in ['hostname', 'username']):
339 # Get the path to the previously generated ssh private key.
340 # Machines we're manually provisioned must have N2VC's public
341 # key injected, so if we don't have a keypair, raise an error.
342 private_key_path
= ""
344 # Enlist the existing machine in Juju
345 machine
= await self
.model
.add_machine(
346 spec
='ssh:{}@{}:{}'.format(
352 # Set the machine id that the deploy below will use.
356 #######################################
357 # Get the initial charm configuration #
358 #######################################
361 if 'rw_mgmt_ip' in params
:
362 rw_mgmt_ip
= params
['rw_mgmt_ip']
364 if 'initial-config-primitive' not in params
:
365 params
['initial-config-primitive'] = {}
367 initial_config
= self
._get
_config
_from
_dict
(
368 params
['initial-config-primitive'],
369 {'<rw_mgmt_ip>': rw_mgmt_ip
}
372 self
.log
.debug("JujuApi: Deploying charm ({}) from {}".format(
378 ########################################################
379 # Deploy the charm and apply the initial configuration #
380 ########################################################
381 app
= await model
.deploy(
382 # We expect charm_path to be either the path to the charm on disk
383 # or in the format of cs:series/name
385 # This is the formatted, unique name for this charm
386 application_name
=application_name
,
387 # Proxy charms should use the current LTS. This will need to be
388 # changed for native charms.
390 # Apply the initial 'config' primitive during deployment
391 config
=initial_config
,
392 # Where to deploy the charm to.
396 # #######################################
397 # # Execute initial config primitive(s) #
398 # #######################################
399 await self
.ExecuteInitialPrimitives(
407 # # Build a sequential list of the primitives to execute
408 # for primitive in params['initial-config-primitive']:
410 # if primitive['name'] == 'config':
411 # # This is applied when the Application is deployed
414 # seq = primitive['seq']
417 # if 'parameter' in primitive:
418 # params = primitive['parameter']
420 # primitives[seq] = {
421 # 'name': primitive['name'],
422 # 'parameters': self._map_primitive_parameters(
424 # {'<rw_mgmt_ip>': rw_mgmt_ip}
428 # for primitive in sorted(primitives):
429 # await self.ExecutePrimitive(
432 # primitives[primitive]['name'],
435 # **primitives[primitive]['parameters'],
437 # except N2VCPrimitiveExecutionFailed as e:
439 # "[N2VC] Exception executing primitive: {}".format(e)
443 async def GetPrimitiveStatus(self
, model_name
, uuid
):
444 """Get the status of an executed Primitive.
446 The status of an executed Primitive will be one of three values:
453 if not self
.authenticated
:
456 # FIXME: This is hard-coded until model-per-ns is added
457 model_name
= 'default'
459 model
= await self
.get_model(model_name
)
461 results
= await model
.get_action_status(uuid
)
464 status
= results
[uuid
]
466 except Exception as e
:
468 "Caught exception while getting primitive status: {}".format(e
)
470 raise N2VCPrimitiveExecutionFailed(e
)
474 async def GetPrimitiveOutput(self
, model_name
, uuid
):
475 """Get the output of an executed Primitive.
477 Note: this only returns output for a successfully executed primitive.
481 if not self
.authenticated
:
484 # FIXME: This is hard-coded until model-per-ns is added
485 model_name
= 'default'
487 model
= await self
.get_model(model_name
)
488 results
= await model
.get_action_output(uuid
, 60)
489 except Exception as e
:
491 "Caught exception while getting primitive status: {}".format(e
)
493 raise N2VCPrimitiveExecutionFailed(e
)
497 # async def ProvisionMachine(self, model_name, hostname, username):
498 # """Provision machine for usage with Juju.
500 # Provisions a previously instantiated machine for use with Juju.
503 # if not self.authenticated:
506 # # FIXME: This is hard-coded until model-per-ns is added
507 # model_name = 'default'
509 # model = await self.get_model(model_name)
510 # model.add_machine(spec={})
512 # machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
519 # except Exception as e:
521 # "Caught exception while getting primitive status: {}".format(e)
523 # raise N2VCPrimitiveExecutionFailed(e)
525 def GetPrivateKeyPath(self
):
526 homedir
= os
.environ
['HOME']
527 sshdir
= "{}/.ssh".format(homedir
)
528 private_key_path
= "{}/id_n2vc_rsa".format(sshdir
)
529 return private_key_path
531 async def GetPublicKey(self
):
532 """Get the N2VC SSH public key.abs
534 Returns the SSH public key, to be injected into virtual machines to
535 be managed by the VCA.
537 The first time this is run, a ssh keypair will be created. The public
538 key is injected into a VM so that we can provision the machine with
539 Juju, after which Juju will communicate with the VM directly via the
544 # Find the path to where we expect our key to live.
545 homedir
= os
.environ
['HOME']
546 sshdir
= "{}/.ssh".format(homedir
)
547 if not os
.path
.exists(sshdir
):
550 private_key_path
= "{}/id_n2vc_rsa".format(sshdir
)
551 public_key_path
= "{}.pub".format(private_key_path
)
553 # If we don't have a key generated, generate it.
554 if not os
.path
.exists(private_key_path
):
555 cmd
= "ssh-keygen -t {} -b {} -N '' -f {}".format(
560 subprocess
.check_output(shlex
.split(cmd
))
562 # Read the public key
563 with
open(public_key_path
, "r") as f
:
564 public_key
= f
.readline()
568 async def ExecuteInitialPrimitives(self
, model_name
, application_name
,
569 params
, callback
=None, *callback_args
):
570 """Execute multiple primitives.
572 Execute multiple primitives as declared in initial-config-primitive.
573 This is useful in cases where the primitives initially failed -- for
574 example, if the charm is a proxy but the proxy hasn't been configured
580 # Build a sequential list of the primitives to execute
581 for primitive
in params
['initial-config-primitive']:
583 if primitive
['name'] == 'config':
586 seq
= primitive
['seq']
589 if 'parameter' in primitive
:
590 params
= primitive
['parameter']
593 'name': primitive
['name'],
594 'parameters': self
._map
_primitive
_parameters
(
596 {'<rw_mgmt_ip>': None}
600 for primitive
in sorted(primitives
):
602 await self
.ExecutePrimitive(
605 primitives
[primitive
]['name'],
608 **primitives
[primitive
]['parameters'],
611 except N2VCPrimitiveExecutionFailed
as e
:
613 "[N2VC] Exception executing primitive: {}".format(e
)
618 async def ExecutePrimitive(self
, model_name
, application_name
, primitive
,
619 callback
, *callback_args
, **params
):
620 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
622 Execute a primitive defined in the VNF descriptor.
624 :param str model_name: The name of the network service.
625 :param str application_name: The name of the application
626 :param str primitive: The name of the primitive to execute.
627 :param obj callback: A callback function to receive status changes.
628 :param tuple callback_args: A list of arguments to be passed to the
630 :param dict params: A dictionary of key=value pairs representing the
631 primitive's parameters
634 'rw_mgmt_ip': '1.2.3.4',
635 # Pass the initial-config-primitives section of the vnf or vdu
636 'initial-config-primitives': {...}
639 self
.log
.debug("Executing {}".format(primitive
))
642 if not self
.authenticated
:
645 # FIXME: This is hard-coded until model-per-ns is added
646 model_name
= 'default'
648 model
= await self
.get_model(model_name
)
650 if primitive
== 'config':
651 # config is special, and expecting params to be a dictionary
652 await self
.set_config(
658 app
= await self
.get_application(model
, application_name
)
660 # Run against the first (and probably only) unit in the app
663 action
= await unit
.run_action(primitive
, **params
)
665 except Exception as e
:
667 "Caught exception while executing primitive: {}".format(e
)
669 raise N2VCPrimitiveExecutionFailed(e
)
672 async def RemoveCharms(self
, model_name
, application_name
, callback
=None,
674 """Remove a charm from the VCA.
676 Remove a charm referenced in a VNF Descriptor.
678 :param str model_name: The name of the network service.
679 :param str application_name: The name of the application
680 :param obj callback: A callback function to receive status changes.
681 :param tuple callback_args: A list of arguments to be passed to the
685 if not self
.authenticated
:
688 model
= await self
.get_model(model_name
)
689 app
= await self
.get_application(model
, application_name
)
691 # Remove this application from event monitoring
692 self
.monitors
[model_name
].RemoveApplication(application_name
)
694 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
696 "Removing the application {}".format(application_name
)
700 # Notify the callback that this charm has been removed.
701 self
.notify_callback(
709 except Exception as e
:
710 print("Caught exception: {}".format(e
))
714 async def DestroyNetworkService(self
, nsd
):
715 raise NotImplementedError()
717 async def GetMetrics(self
, model_name
, application_name
):
718 """Get the metrics collected by the VCA.
720 :param model_name The name of the model
721 :param application_name The name of the application
724 model
= await self
.get_model(model_name
)
725 app
= await self
.get_application(model
, application_name
)
727 metrics
= await app
.get_metrics()
731 async def HasApplication(self
, model_name
, application_name
):
732 model
= await self
.get_model(model_name
)
733 app
= await self
.get_application(model
, application_name
)
739 async def add_relation(self
, a
, b
, via
=None):
741 Add a relation between two application endpoints.
743 :param a An application endpoint
744 :param b An application endpoint
745 :param via The egress subnet(s) for outbound traffic, e.g.,
746 (192.168.0.0/16,10.0.0.0/8)
748 if not self
.authenticated
:
751 m
= await self
.get_model()
753 m
.add_relation(a
, b
, via
)
757 # async def apply_config(self, config, application):
758 # """Apply a configuration to the application."""
759 # print("JujuApi: Applying configuration to {}.".format(
762 # return await self.set_config(application=application, config=config)
764 def _get_config_from_dict(self
, config_primitive
, values
):
765 """Transform the yang config primitive to dict.
774 for primitive
in config_primitive
:
775 if primitive
['name'] == 'config':
776 # config = self._map_primitive_parameters()
777 for parameter
in primitive
['parameter']:
778 param
= str(parameter
['name'])
779 if parameter
['value'] == "<rw_mgmt_ip>":
780 config
[param
] = str(values
[parameter
['value']])
782 config
[param
] = str(parameter
['value'])
786 def _map_primitive_parameters(self
, parameters
, values
):
788 for parameter
in parameters
:
789 param
= str(parameter
['name'])
791 # Typecast parameter value, if present
792 if 'data-type' in parameter
:
793 paramtype
= str(parameter
['data-type']).lower()
796 if paramtype
== "integer":
797 value
= int(parameter
['value'])
798 elif paramtype
== "boolean":
799 value
= bool(parameter
['value'])
801 value
= str(parameter
['value'])
803 if parameter
['value'] == "<rw_mgmt_ip>":
804 params
[param
] = str(values
[parameter
['value']])
806 params
[param
] = value
809 def _get_config_from_yang(self
, config_primitive
, values
):
810 """Transform the yang config primitive to dict."""
812 for primitive
in config_primitive
.values():
813 if primitive
['name'] == 'config':
814 for parameter
in primitive
['parameter'].values():
815 param
= str(parameter
['name'])
816 if parameter
['value'] == "<rw_mgmt_ip>":
817 config
[param
] = str(values
[parameter
['value']])
819 config
[param
] = str(parameter
['value'])
824 def FormatApplicationName(self
, *args
):
826 Generate a Juju-compatible Application name
828 :param args tuple: Positional arguments to be used to construct the
832 - Only accepts characters a-z and non-consequitive dashes (-)
833 - Application name should not exceed 50 characters
837 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
841 for c
in "-".join(list(args
)):
844 elif not c
.isalpha():
847 return re
.sub('\-+', '-', appname
.lower())
849 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
850 # """Format the name of the application
853 # - Only accepts characters a-z and non-consequitive dashes (-)
854 # - Application name should not exceed 50 characters
856 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
860 # c = chr(97 + int(c))
861 # elif not c.isalpha():
864 # return re.sub('\-+', '-', new_name.lower())
866 def format_model_name(self
, name
):
867 """Format the name of model.
869 Model names may only contain lowercase letters, digits and hyphens
872 return name
.replace('_', '-').lower()
874 async def get_application(self
, model
, application
):
875 """Get the deployed application."""
876 if not self
.authenticated
:
880 if application
and model
:
881 if model
.applications
:
882 if application
in model
.applications
:
883 app
= model
.applications
[application
]
887 async def get_model(self
, model_name
='default'):
888 """Get a model from the Juju Controller.
890 Note: Model objects returned must call disconnected() before it goes
892 if not self
.authenticated
:
895 if model_name
not in self
.models
:
896 self
.models
[model_name
] = await self
.controller
.get_model(
899 self
.refcount
['model'] += 1
901 # Create an observer for this model
902 self
.monitors
[model_name
] = VCAMonitor(model_name
)
903 self
.models
[model_name
].add_observer(self
.monitors
[model_name
])
905 return self
.models
[model_name
]
907 async def login(self
):
908 """Login to the Juju controller."""
910 if self
.authenticated
:
913 self
.connecting
= True
915 self
.log
.debug("JujuApi: Logging into controller")
918 self
.controller
= Controller(loop
=self
.loop
)
922 "Connecting to controller... ws://{}:{} as {}/{}".format(
929 await self
.controller
.connect(
930 endpoint
=self
.endpoint
,
932 password
=self
.secret
,
935 self
.refcount
['controller'] += 1
937 # current_controller no longer exists
938 # self.log.debug("Connecting to current controller...")
939 # await self.controller.connect_current()
940 # await self.controller.connect(
941 # endpoint=self.endpoint,
942 # username=self.user,
945 self
.log
.fatal("VCA credentials not configured.")
947 self
.authenticated
= True
948 self
.log
.debug("JujuApi: Logged into controller")
950 async def logout(self
):
951 """Logout of the Juju controller."""
952 if not self
.authenticated
:
956 if self
.default_model
:
957 self
.log
.debug("Disconnecting model {}".format(
960 await self
.default_model
.disconnect()
961 self
.refcount
['model'] -= 1
962 self
.default_model
= None
964 for model
in self
.models
:
965 await self
.models
[model
].disconnect()
966 self
.refcount
['model'] -= 1
967 self
.models
[model
] = None
970 self
.log
.debug("Disconnecting controller {}".format(
973 await self
.controller
.disconnect()
974 self
.refcount
['controller'] -= 1
975 self
.controller
= None
977 self
.authenticated
= False
979 self
.log
.debug(self
.refcount
)
981 except Exception as e
:
983 "Fatal error logging out of Juju Controller: {}".format(e
)
987 # async def remove_application(self, name):
988 # """Remove the application."""
989 # if not self.authenticated:
992 # app = await self.get_application(name)
994 # self.log.debug("JujuApi: Destroying application {}".format(
998 # await app.destroy()
1000 async def remove_relation(self
, a
, b
):
1002 Remove a relation between two application endpoints
1004 :param a An application endpoint
1005 :param b An application endpoint
1007 if not self
.authenticated
:
1010 m
= await self
.get_model()
1012 m
.remove_relation(a
, b
)
1014 await m
.disconnect()
1016 async def resolve_error(self
, application
=None):
1017 """Resolve units in error state."""
1018 if not self
.authenticated
:
1021 app
= await self
.get_application(self
.default_model
, application
)
1024 "JujuApi: Resolving errors for application {}".format(
1029 for unit
in app
.units
:
1030 app
.resolved(retry
=True)
1032 async def run_action(self
, application
, action_name
, **params
):
1033 """Execute an action and return an Action object."""
1034 if not self
.authenticated
:
1043 app
= await self
.get_application(self
.default_model
, application
)
1045 # We currently only have one unit per application
1046 # so use the first unit available.
1050 "JujuApi: Running Action {} against Application {}".format(
1056 action
= await unit
.run_action(action_name
, **params
)
1058 # Wait for the action to complete
1061 result
['status'] = action
.status
1062 result
['action']['tag'] = action
.data
['id']
1063 result
['action']['results'] = action
.results
1067 async def set_config(self
, model_name
, application
, config
):
1068 """Apply a configuration to the application."""
1069 if not self
.authenticated
:
1072 app
= await self
.get_application(model_name
, application
)
1074 self
.log
.debug("JujuApi: Setting config for Application {}".format(
1077 await app
.set_config(config
)
1079 # Verify the config is set
1080 newconf
= await app
.get_config()
1082 if config
[key
] != newconf
[key
]['value']:
1083 self
.log
.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key
, config
[key
], newconf
[key
]))
1085 # async def set_parameter(self, parameter, value, application=None):
1086 # """Set a config parameter for a service."""
1087 # if not self.authenticated:
1088 # await self.login()
1090 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1095 # return await self.apply_config(
1096 # {parameter: value},
1097 # application=application,
1100 async def wait_for_application(self
, model_name
, application_name
,
1102 """Wait for an application to become active."""
1103 if not self
.authenticated
:
1106 # TODO: In a point release, we will use a model per deployed network
1107 # service. In the meantime, we will always use the 'default' model.
1108 model_name
= 'default'
1109 model
= await self
.get_model(model_name
)
1111 app
= await self
.get_application(model
, application_name
)
1112 self
.log
.debug("Application: {}".format(app
))
1113 # app = await self.get_application(model_name, application_name)
1116 "JujuApi: Waiting {} seconds for Application {}".format(
1122 await model
.block_until(
1124 unit
.agent_status
== 'idle' and unit
.workload_status
in
1125 ['active', 'unknown'] for unit
in app
.units