blob: 9f0440538377d8c0cf0d918d3e1af6c19b13e09b [file] [log] [blame]
Adam Israel5e08a0e2018-09-06 19:22:47 -04001import asyncio
Adam Israelc3e6c2e2018-03-01 09:31:50 -05002import logging
3import os
4import os.path
5import re
Adam Israelfa329072018-09-14 11:26:13 -04006import shlex
Adam Israelc3e6c2e2018-03-01 09:31:50 -05007import ssl
Adam Israelfa329072018-09-14 11:26:13 -04008import subprocess
Adam Israelc3e6c2e2018-03-01 09:31:50 -05009import sys
Adam Israel5e08a0e2018-09-06 19:22:47 -040010# import time
Adam Israelc3e6c2e2018-03-01 09:31:50 -050011
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
15path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
16path = os.path.join(path, "modules/libjuju/")
17if path not in sys.path:
18 sys.path.insert(1, path)
19
20from juju.controller import Controller
Adam Israel5e08a0e2018-09-06 19:22:47 -040021from juju.model import ModelObserver
Adam Israelc3e6c2e2018-03-01 09:31:50 -050022
23
24# We might need this to connect to the websocket securely, but test and verify.
25try:
26 ssl._create_default_https_context = ssl._create_unverified_context
27except AttributeError:
28 # Legacy Python doesn't verify by default (see pep-0476)
29 # https://www.python.org/dev/peps/pep-0476/
30 pass
31
32
33# Custom exceptions
34class JujuCharmNotFound(Exception):
35 """The Charm can't be found or is not readable."""
36
37
38class JujuApplicationExists(Exception):
39 """The Application already exists."""
40
Adam Israelb5214512018-05-03 10:00:04 -040041
Adam Israel88a49632018-04-10 13:04:57 -060042class N2VCPrimitiveExecutionFailed(Exception):
43 """Something failed while attempting to execute a primitive."""
44
Adam Israelc3e6c2e2018-03-01 09:31:50 -050045
46# Quiet the debug logging
47logging.getLogger('websockets.protocol').setLevel(logging.INFO)
48logging.getLogger('juju.client.connection').setLevel(logging.WARN)
49logging.getLogger('juju.model').setLevel(logging.WARN)
50logging.getLogger('juju.machine').setLevel(logging.WARN)
51
Adam Israelb5214512018-05-03 10:00:04 -040052
Adam Israelc3e6c2e2018-03-01 09:31:50 -050053class VCAMonitor(ModelObserver):
54 """Monitor state changes within the Juju Model."""
Adam Israelc3e6c2e2018-03-01 09:31:50 -050055 log = None
56 ns_name = None
Adam Israel28a43c02018-04-23 16:04:54 -040057 applications = {}
Adam Israelc3e6c2e2018-03-01 09:31:50 -050058
Adam Israel28a43c02018-04-23 16:04:54 -040059 def __init__(self, ns_name):
Adam Israelc3e6c2e2018-03-01 09:31:50 -050060 self.log = logging.getLogger(__name__)
61
62 self.ns_name = ns_name
Adam Israel28a43c02018-04-23 16:04:54 -040063
64 def AddApplication(self, application_name, callback, *callback_args):
65 if application_name not in self.applications:
66 self.applications[application_name] = {
67 'callback': callback,
68 'callback_args': callback_args
69 }
70
71 def RemoveApplication(self, application_name):
72 if application_name in self.applications:
73 del self.applications[application_name]
Adam Israelc3e6c2e2018-03-01 09:31:50 -050074
75 async def on_change(self, delta, old, new, model):
76 """React to changes in the Juju model."""
77
78 if delta.entity == "unit":
Adam Israel28a43c02018-04-23 16:04:54 -040079 # Ignore change events from other applications
80 if delta.data['application'] not in self.applications.keys():
81 return
82
Adam Israelc3e6c2e2018-03-01 09:31:50 -050083 try:
Adam Israel28a43c02018-04-23 16:04:54 -040084
85 application_name = delta.data['application']
86
87 callback = self.applications[application_name]['callback']
Adam Israel5e08a0e2018-09-06 19:22:47 -040088 callback_args = \
89 self.applications[application_name]['callback_args']
Adam Israel28a43c02018-04-23 16:04:54 -040090
Adam Israelc3e6c2e2018-03-01 09:31:50 -050091 if old and new:
Adam Israelfc511ed2018-09-21 14:20:55 +020092 # Fire off a callback with the application state
93 if callback:
94 callback(
95 self.ns_name,
96 delta.data['application'],
97 new.workload_status,
98 new.workload_status_message,
99 *callback_args)
Adam Israel28a43c02018-04-23 16:04:54 -0400100
101 if old and not new:
102 # This is a charm being removed
103 if callback:
104 callback(
105 self.ns_name,
106 delta.data['application'],
107 "removed",
Adam Israel9562f432018-05-09 13:55:28 -0400108 "",
Adam Israel28a43c02018-04-23 16:04:54 -0400109 *callback_args)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500110 except Exception as e:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400111 self.log.debug("[1] notify_callback exception: {}".format(e))
112
Adam Israel88a49632018-04-10 13:04:57 -0600113 elif delta.entity == "action":
114 # TODO: Decide how we want to notify the user of actions
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500115
Adam Israel88a49632018-04-10 13:04:57 -0600116 # uuid = delta.data['id'] # The Action's unique id
117 # msg = delta.data['message'] # The output of the action
118 #
119 # if delta.data['status'] == "pending":
120 # # The action is queued
121 # pass
122 # elif delta.data['status'] == "completed""
123 # # The action was successful
124 # pass
125 # elif delta.data['status'] == "failed":
126 # # The action failed.
127 # pass
128
129 pass
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500130
131########
132# TODO
133#
134# Create unique models per network service
135# Document all public functions
136
Adam Israelb5214512018-05-03 10:00:04 -0400137
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500138class N2VC:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500139 def __init__(self,
140 log=None,
141 server='127.0.0.1',
142 port=17070,
143 user='admin',
144 secret=None,
Adam Israel5e08a0e2018-09-06 19:22:47 -0400145 artifacts=None,
146 loop=None,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500147 ):
148 """Initialize N2VC
149
150 :param vcaconfig dict A dictionary containing the VCA configuration
151
152 :param artifacts str The directory where charms required by a vnfd are
153 stored.
154
155 :Example:
156 n2vc = N2VC(vcaconfig={
157 'secret': 'MzI3MDJhOTYxYmM0YzRjNTJiYmY1Yzdm',
158 'user': 'admin',
159 'ip-address': '10.44.127.137',
160 'port': 17070,
161 'artifacts': '/path/to/charms'
162 })
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500163 """
164
Adam Israel5e08a0e2018-09-06 19:22:47 -0400165 # Initialize instance-level variables
166 self.api = None
167 self.log = None
168 self.controller = None
169 self.connecting = False
170 self.authenticated = False
171
Adam Israelfc511ed2018-09-21 14:20:55 +0200172 # For debugging
173 self.refcount = {
174 'controller': 0,
175 'model': 0,
176 }
177
Adam Israel5e08a0e2018-09-06 19:22:47 -0400178 self.models = {}
179 self.default_model = None
180
181 # Model Observers
182 self.monitors = {}
183
184 # VCA config
185 self.hostname = ""
186 self.port = 17070
187 self.username = ""
188 self.secret = ""
189
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500190 if log:
191 self.log = log
192 else:
193 self.log = logging.getLogger(__name__)
194
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)
200
201 self.log.debug('JujuApi: instantiated')
202
203 self.server = server
204 self.port = port
205
206 self.secret = secret
207 if user.startswith('user-'):
208 self.user = user
209 else:
210 self.user = 'user-{}'.format(user)
211
212 self.endpoint = '%s:%d' % (server, int(port))
213
214 self.artifacts = artifacts
215
Adam Israel5e08a0e2018-09-06 19:22:47 -0400216 self.loop = loop or asyncio.get_event_loop()
217
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500218 def __del__(self):
219 """Close any open connections."""
220 yield self.logout()
221
Adam Israel5e08a0e2018-09-06 19:22:47 -0400222 def notify_callback(self, model_name, application_name, status, message,
223 callback=None, *callback_args):
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500224 try:
225 if callback:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400226 callback(
227 model_name,
228 application_name,
229 status, message,
230 *callback_args,
231 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500232 except Exception as e:
233 self.log.error("[0] notify_callback exception {}".format(e))
Adam Israel88a49632018-04-10 13:04:57 -0600234 raise e
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500235 return True
236
237 # Public methods
238 async def CreateNetworkService(self, nsd):
239 """Create a new model to encapsulate this network service.
240
241 Create a new model in the Juju controller to encapsulate the
242 charms associated with a network service.
243
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.
246 """
247 if not self.authenticated:
248 await self.login()
249
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
254
255 return self.default_model
256
Adam Israel5e08a0e2018-09-06 19:22:47 -0400257 async def DeployCharms(self, model_name, application_name, vnfd,
258 charm_path, params={}, machine_spec={},
259 callback=None, *callback_args):
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500260 """Deploy one or more charms associated with a VNF.
261
262 Deploy the charm(s) referenced in a VNF Descriptor.
263
Adam Israelc9df96f2018-05-03 14:49:56 -0400264 :param str model_name: The name of the network service.
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500265 :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
269 Examples::
270 {
Adam Israel88a49632018-04-10 13:04:57 -0600271 'rw_mgmt_ip': '1.2.3.4',
272 # Pass the initial-config-primitives section of the vnf or vdu
273 'initial-config-primitives': {...}
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500274 }
Adam Israel5e08a0e2018-09-06 19:22:47 -0400275 :param dict machine_spec: A dictionary describing the machine to
276 install to
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500277 Examples::
278 {
279 'hostname': '1.2.3.4',
280 'username': 'ubuntu',
281 }
282 :param obj callback: A callback function to receive status changes.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400283 :param tuple callback_args: A list of arguments to be passed to the
284 callback
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500285 """
286
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))
Adam Israel5e08a0e2018-09-06 19:22:47 -0400292 self.notify_callback(
293 model_name,
294 application_name,
295 "failed",
296 callback,
297 *callback_args,
298 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500299 raise JujuCharmNotFound("No artifacts configured.")
300
301 ################################
302 # Login to the Juju controller #
303 ################################
304 if not self.authenticated:
305 self.log.debug("Authenticating with Juju")
306 await self.login()
307
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)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500315
316 ########################################
317 # Verify the application doesn't exist #
318 ########################################
319 app = await self.get_application(model, application_name)
320 if app:
Adam Israel42d88e62018-07-16 14:18:41 -0400321 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name, model_name))
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500322
Adam Israel28a43c02018-04-23 16:04:54 -0400323 ################################################################
324 # Register this application with the model-level event monitor #
325 ################################################################
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500326 if callback:
Adam Israel28a43c02018-04-23 16:04:54 -0400327 self.monitors[model_name].AddApplication(
328 application_name,
329 callback,
330 *callback_args
331 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500332
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500333 ########################################################
334 # Check for specific machine placement (native charms) #
335 ########################################################
336 to = ""
337 if machine_spec.keys():
Adam Israel1ddca812018-09-14 11:32:35 -0400338 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 = ""
343
344 # Enlist the existing machine in Juju
345 machine = await self.model.add_machine(
346 spec='ssh:{}@{}:{}'.format(
347 specs['host'],
348 specs['user'],
349 private_key_path,
350 )
351 )
352 # Set the machine id that the deploy below will use.
Adam Israelfa329072018-09-14 11:26:13 -0400353 to = machine.id
Adam Israel1ddca812018-09-14 11:32:35 -0400354 pass
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500355
356 #######################################
357 # Get the initial charm configuration #
358 #######################################
359
360 rw_mgmt_ip = None
361 if 'rw_mgmt_ip' in params:
362 rw_mgmt_ip = params['rw_mgmt_ip']
363
Adam Israel5afe0542018-08-08 12:54:55 -0400364 if 'initial-config-primitive' not in params:
365 params['initial-config-primitive'] = {}
366
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500367 initial_config = self._get_config_from_dict(
Adam Israel88a49632018-04-10 13:04:57 -0600368 params['initial-config-primitive'],
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500369 {'<rw_mgmt_ip>': rw_mgmt_ip}
370 )
371
Adam Israel88a49632018-04-10 13:04:57 -0600372 self.log.debug("JujuApi: Deploying charm ({}) from {}".format(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500373 application_name,
374 charm_path,
375 to=to,
376 ))
377
378 ########################################################
379 # Deploy the charm and apply the initial configuration #
380 ########################################################
381 app = await model.deploy(
Adam Israel88a49632018-04-10 13:04:57 -0600382 # We expect charm_path to be either the path to the charm on disk
383 # or in the format of cs:series/name
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500384 charm_path,
Adam Israel88a49632018-04-10 13:04:57 -0600385 # This is the formatted, unique name for this charm
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500386 application_name=application_name,
Adam Israel88a49632018-04-10 13:04:57 -0600387 # Proxy charms should use the current LTS. This will need to be
388 # changed for native charms.
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500389 series='xenial',
Adam Israel88a49632018-04-10 13:04:57 -0600390 # Apply the initial 'config' primitive during deployment
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500391 config=initial_config,
Adam Israelfa329072018-09-14 11:26:13 -0400392 # Where to deploy the charm to.
393 to=to,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500394 )
395
Adam Israel88a49632018-04-10 13:04:57 -0600396 # #######################################
397 # # Execute initial config primitive(s) #
398 # #######################################
Adam Israel5e08a0e2018-09-06 19:22:47 -0400399 await self.ExecuteInitialPrimitives(
400 model_name,
401 application_name,
402 params,
403 )
404
405 # primitives = {}
406 #
407 # # Build a sequential list of the primitives to execute
408 # for primitive in params['initial-config-primitive']:
409 # try:
410 # if primitive['name'] == 'config':
411 # # This is applied when the Application is deployed
412 # pass
413 # else:
414 # seq = primitive['seq']
415 #
416 # params = {}
417 # if 'parameter' in primitive:
418 # params = primitive['parameter']
419 #
420 # primitives[seq] = {
421 # 'name': primitive['name'],
422 # 'parameters': self._map_primitive_parameters(
423 # params,
424 # {'<rw_mgmt_ip>': rw_mgmt_ip}
425 # ),
426 # }
427 #
428 # for primitive in sorted(primitives):
429 # await self.ExecutePrimitive(
430 # model_name,
431 # application_name,
432 # primitives[primitive]['name'],
433 # callback,
434 # callback_args,
435 # **primitives[primitive]['parameters'],
436 # )
437 # except N2VCPrimitiveExecutionFailed as e:
438 # self.log.debug(
439 # "[N2VC] Exception executing primitive: {}".format(e)
440 # )
441 # raise
442
443 async def GetPrimitiveStatus(self, model_name, uuid):
444 """Get the status of an executed Primitive.
445
446 The status of an executed Primitive will be one of three values:
447 - completed
448 - failed
449 - running
450 """
451 status = None
452 try:
453 if not self.authenticated:
454 await self.login()
455
456 # FIXME: This is hard-coded until model-per-ns is added
457 model_name = 'default'
458
459 model = await self.get_model(model_name)
460
461 results = await model.get_action_status(uuid)
462
463 if uuid in results:
464 status = results[uuid]
465
466 except Exception as e:
467 self.log.debug(
468 "Caught exception while getting primitive status: {}".format(e)
469 )
470 raise N2VCPrimitiveExecutionFailed(e)
471
472 return status
473
474 async def GetPrimitiveOutput(self, model_name, uuid):
475 """Get the output of an executed Primitive.
476
477 Note: this only returns output for a successfully executed primitive.
478 """
479 results = None
480 try:
481 if not self.authenticated:
482 await self.login()
483
484 # FIXME: This is hard-coded until model-per-ns is added
485 model_name = 'default'
486
487 model = await self.get_model(model_name)
488 results = await model.get_action_output(uuid, 60)
489 except Exception as e:
490 self.log.debug(
491 "Caught exception while getting primitive status: {}".format(e)
492 )
493 raise N2VCPrimitiveExecutionFailed(e)
494
495 return results
496
Adam Israelfa329072018-09-14 11:26:13 -0400497 # async def ProvisionMachine(self, model_name, hostname, username):
498 # """Provision machine for usage with Juju.
499 #
500 # Provisions a previously instantiated machine for use with Juju.
501 # """
502 # try:
503 # if not self.authenticated:
504 # await self.login()
505 #
506 # # FIXME: This is hard-coded until model-per-ns is added
507 # model_name = 'default'
508 #
509 # model = await self.get_model(model_name)
510 # model.add_machine(spec={})
511 #
512 # machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
513 # "ubuntu",
514 # host['address'],
515 # private_key_path,
516 # ))
517 # return machine.id
518 #
519 # except Exception as e:
520 # self.log.debug(
521 # "Caught exception while getting primitive status: {}".format(e)
522 # )
523 # raise N2VCPrimitiveExecutionFailed(e)
524
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
530
531 async def GetPublicKey(self):
532 """Get the N2VC SSH public key.abs
533
534 Returns the SSH public key, to be injected into virtual machines to
535 be managed by the VCA.
536
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
540 juju agent.
541 """
542 public_key = ""
543
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):
548 os.mkdir(sshdir)
549
550 private_key_path = "{}/id_n2vc_rsa".format(sshdir)
551 public_key_path = "{}.pub".format(private_key_path)
552
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(
556 "rsa",
557 "4096",
558 private_key_path
559 )
560 subprocess.check_output(shlex.split(cmd))
561
562 # Read the public key
563 with open(public_key_path, "r") as f:
564 public_key = f.readline()
565
566 return public_key
567
Adam Israel5e08a0e2018-09-06 19:22:47 -0400568 async def ExecuteInitialPrimitives(self, model_name, application_name,
569 params, callback=None, *callback_args):
570 """Execute multiple primitives.
571
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
575 yet.
576 """
577 uuids = []
Adam Israel88a49632018-04-10 13:04:57 -0600578 primitives = {}
579
580 # Build a sequential list of the primitives to execute
581 for primitive in params['initial-config-primitive']:
582 try:
583 if primitive['name'] == 'config':
Adam Israel88a49632018-04-10 13:04:57 -0600584 pass
585 else:
Adam Israel88a49632018-04-10 13:04:57 -0600586 seq = primitive['seq']
587
Adam Israel42d88e62018-07-16 14:18:41 -0400588 params = {}
589 if 'parameter' in primitive:
590 params = primitive['parameter']
591
Adam Israel88a49632018-04-10 13:04:57 -0600592 primitives[seq] = {
593 'name': primitive['name'],
594 'parameters': self._map_primitive_parameters(
Adam Israel42d88e62018-07-16 14:18:41 -0400595 params,
Adam Israel5e08a0e2018-09-06 19:22:47 -0400596 {'<rw_mgmt_ip>': None}
Adam Israel88a49632018-04-10 13:04:57 -0600597 ),
598 }
599
600 for primitive in sorted(primitives):
Adam Israel5e08a0e2018-09-06 19:22:47 -0400601 uuids.append(
602 await self.ExecutePrimitive(
603 model_name,
604 application_name,
605 primitives[primitive]['name'],
606 callback,
607 callback_args,
608 **primitives[primitive]['parameters'],
609 )
Adam Israel88a49632018-04-10 13:04:57 -0600610 )
611 except N2VCPrimitiveExecutionFailed as e:
Adam Israel7d871fb2018-07-17 12:17:06 -0400612 self.log.debug(
Adam Israel88a49632018-04-10 13:04:57 -0600613 "[N2VC] Exception executing primitive: {}".format(e)
614 )
615 raise
Adam Israel5e08a0e2018-09-06 19:22:47 -0400616 return uuids
Adam Israel88a49632018-04-10 13:04:57 -0600617
Adam Israel5e08a0e2018-09-06 19:22:47 -0400618 async def ExecutePrimitive(self, model_name, application_name, primitive,
619 callback, *callback_args, **params):
Adam Israelc9df96f2018-05-03 14:49:56 -0400620 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
Adam Israel6817f612018-04-13 08:41:43 -0600621
Adam Israelc9df96f2018-05-03 14:49:56 -0400622 Execute a primitive defined in the VNF descriptor.
623
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.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400628 :param tuple callback_args: A list of arguments to be passed to the
629 callback function.
630 :param dict params: A dictionary of key=value pairs representing the
631 primitive's parameters
Adam Israelc9df96f2018-05-03 14:49:56 -0400632 Examples::
633 {
634 'rw_mgmt_ip': '1.2.3.4',
635 # Pass the initial-config-primitives section of the vnf or vdu
636 'initial-config-primitives': {...}
637 }
Adam Israel6817f612018-04-13 08:41:43 -0600638 """
Adam Israel5e08a0e2018-09-06 19:22:47 -0400639 self.log.debug("Executing {}".format(primitive))
Adam Israel6817f612018-04-13 08:41:43 -0600640 uuid = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500641 try:
642 if not self.authenticated:
643 await self.login()
644
645 # FIXME: This is hard-coded until model-per-ns is added
646 model_name = 'default'
647
Adam Israel5e08a0e2018-09-06 19:22:47 -0400648 model = await self.get_model(model_name)
Adam Israelb5214512018-05-03 10:00:04 -0400649
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500650 if primitive == 'config':
651 # config is special, and expecting params to be a dictionary
Adam Israelb0943662018-08-02 15:32:00 -0400652 await self.set_config(
653 model,
654 application_name,
655 params['params'],
656 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500657 else:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500658 app = await self.get_application(model, application_name)
659 if app:
660 # Run against the first (and probably only) unit in the app
661 unit = app.units[0]
662 if unit:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500663 action = await unit.run_action(primitive, **params)
Adam Israel6817f612018-04-13 08:41:43 -0600664 uuid = action.id
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500665 except Exception as e:
Adam Israelb0943662018-08-02 15:32:00 -0400666 self.log.debug(
667 "Caught exception while executing primitive: {}".format(e)
668 )
Adam Israel7d871fb2018-07-17 12:17:06 -0400669 raise N2VCPrimitiveExecutionFailed(e)
Adam Israel6817f612018-04-13 08:41:43 -0600670 return uuid
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500671
Adam Israel5e08a0e2018-09-06 19:22:47 -0400672 async def RemoveCharms(self, model_name, application_name, callback=None,
673 *callback_args):
Adam Israelc9df96f2018-05-03 14:49:56 -0400674 """Remove a charm from the VCA.
675
676 Remove a charm referenced in a VNF Descriptor.
677
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.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400681 :param tuple callback_args: A list of arguments to be passed to the
682 callback function.
Adam Israelc9df96f2018-05-03 14:49:56 -0400683 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500684 try:
685 if not self.authenticated:
686 await self.login()
687
688 model = await self.get_model(model_name)
689 app = await self.get_application(model, application_name)
690 if app:
Adam Israel28a43c02018-04-23 16:04:54 -0400691 # Remove this application from event monitoring
692 self.monitors[model_name].RemoveApplication(application_name)
693
694 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
Adam Israel5e08a0e2018-09-06 19:22:47 -0400695 self.log.debug(
696 "Removing the application {}".format(application_name)
697 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500698 await app.remove()
Adam Israel28a43c02018-04-23 16:04:54 -0400699
700 # Notify the callback that this charm has been removed.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400701 self.notify_callback(
702 model_name,
703 application_name,
704 "removed",
705 callback,
706 *callback_args,
707 )
Adam Israel28a43c02018-04-23 16:04:54 -0400708
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500709 except Exception as e:
710 print("Caught exception: {}".format(e))
Adam Israel88a49632018-04-10 13:04:57 -0600711 self.log.debug(e)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500712 raise e
713
714 async def DestroyNetworkService(self, nsd):
715 raise NotImplementedError()
716
Adam Israelb5214512018-05-03 10:00:04 -0400717 async def GetMetrics(self, model_name, application_name):
718 """Get the metrics collected by the VCA.
719
720 :param model_name The name of the model
721 :param application_name The name of the application
722 """
723 metrics = {}
724 model = await self.get_model(model_name)
725 app = await self.get_application(model, application_name)
726 if app:
727 metrics = await app.get_metrics()
728
729 return metrics
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500730
Adam Israelfa329072018-09-14 11:26:13 -0400731 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)
734 if app:
735 return True
736 return False
737
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500738 # Non-public methods
739 async def add_relation(self, a, b, via=None):
740 """
741 Add a relation between two application endpoints.
742
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)
747 """
748 if not self.authenticated:
749 await self.login()
750
751 m = await self.get_model()
752 try:
753 m.add_relation(a, b, via)
754 finally:
755 await m.disconnect()
756
Adam Israelb5214512018-05-03 10:00:04 -0400757 # async def apply_config(self, config, application):
758 # """Apply a configuration to the application."""
759 # print("JujuApi: Applying configuration to {}.".format(
760 # application
761 # ))
762 # return await self.set_config(application=application, config=config)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500763
764 def _get_config_from_dict(self, config_primitive, values):
Adam Israel88a49632018-04-10 13:04:57 -0600765 """Transform the yang config primitive to dict.
766
767 Expected result:
768
769 config = {
770 'config':
771 }
772 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500773 config = {}
774 for primitive in config_primitive:
775 if primitive['name'] == 'config':
Adam Israel88a49632018-04-10 13:04:57 -0600776 # config = self._map_primitive_parameters()
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500777 for parameter in primitive['parameter']:
778 param = str(parameter['name'])
779 if parameter['value'] == "<rw_mgmt_ip>":
780 config[param] = str(values[parameter['value']])
781 else:
782 config[param] = str(parameter['value'])
783
784 return config
785
Adam Israel88a49632018-04-10 13:04:57 -0600786 def _map_primitive_parameters(self, parameters, values):
787 params = {}
788 for parameter in parameters:
789 param = str(parameter['name'])
Adam Israel5e08a0e2018-09-06 19:22:47 -0400790
791 # Typecast parameter value, if present
792 if 'data-type' in parameter:
793 paramtype = str(parameter['data-type']).lower()
794 value = None
795
796 if paramtype == "integer":
797 value = int(parameter['value'])
798 elif paramtype == "boolean":
799 value = bool(parameter['value'])
800 else:
801 value = str(parameter['value'])
802
Adam Israel88a49632018-04-10 13:04:57 -0600803 if parameter['value'] == "<rw_mgmt_ip>":
804 params[param] = str(values[parameter['value']])
805 else:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400806 params[param] = value
Adam Israel88a49632018-04-10 13:04:57 -0600807 return params
808
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500809 def _get_config_from_yang(self, config_primitive, values):
810 """Transform the yang config primitive to dict."""
811 config = {}
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']])
818 else:
819 config[param] = str(parameter['value'])
820
821 return config
822
Adam Israel5e08a0e2018-09-06 19:22:47 -0400823 @staticmethod
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500824 def FormatApplicationName(self, *args):
825 """
826 Generate a Juju-compatible Application name
827
828 :param args tuple: Positional arguments to be used to construct the
829 application name.
830
831 Limitations::
832 - Only accepts characters a-z and non-consequitive dashes (-)
833 - Application name should not exceed 50 characters
834
835 Examples::
836
837 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
838 """
839
840 appname = ""
841 for c in "-".join(list(args)):
842 if c.isdigit():
843 c = chr(97 + int(c))
844 elif not c.isalpha():
845 c = "-"
846 appname += c
847 return re.sub('\-+', '-', appname.lower())
848
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500849 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
850 # """Format the name of the application
851 #
852 # Limitations:
853 # - Only accepts characters a-z and non-consequitive dashes (-)
854 # - Application name should not exceed 50 characters
855 # """
856 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
857 # new_name = ''
858 # for c in name:
859 # if c.isdigit():
860 # c = chr(97 + int(c))
861 # elif not c.isalpha():
862 # c = "-"
863 # new_name += c
864 # return re.sub('\-+', '-', new_name.lower())
865
866 def format_model_name(self, name):
867 """Format the name of model.
868
869 Model names may only contain lowercase letters, digits and hyphens
870 """
871
872 return name.replace('_', '-').lower()
873
874 async def get_application(self, model, application):
875 """Get the deployed application."""
876 if not self.authenticated:
877 await self.login()
878
879 app = None
880 if application and model:
881 if model.applications:
882 if application in model.applications:
883 app = model.applications[application]
884
885 return app
886
887 async def get_model(self, model_name='default'):
888 """Get a model from the Juju Controller.
889
890 Note: Model objects returned must call disconnected() before it goes
891 out of scope."""
892 if not self.authenticated:
893 await self.login()
894
895 if model_name not in self.models:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400896 self.models[model_name] = await self.controller.get_model(
897 model_name,
898 )
Adam Israelfc511ed2018-09-21 14:20:55 +0200899 self.refcount['model'] += 1
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500900
Adam Israel28a43c02018-04-23 16:04:54 -0400901 # 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])
904
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500905 return self.models[model_name]
906
907 async def login(self):
908 """Login to the Juju controller."""
909
910 if self.authenticated:
911 return
912
913 self.connecting = True
914
915 self.log.debug("JujuApi: Logging into controller")
916
917 cacert = None
Adam Israel5e08a0e2018-09-06 19:22:47 -0400918 self.controller = Controller(loop=self.loop)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500919
920 if self.secret:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400921 self.log.debug(
922 "Connecting to controller... ws://{}:{} as {}/{}".format(
923 self.endpoint,
924 self.port,
925 self.user,
926 self.secret,
927 )
928 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500929 await self.controller.connect(
930 endpoint=self.endpoint,
931 username=self.user,
932 password=self.secret,
933 cacert=cacert,
934 )
Adam Israelfc511ed2018-09-21 14:20:55 +0200935 self.refcount['controller'] += 1
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500936 else:
937 # current_controller no longer exists
938 # self.log.debug("Connecting to current controller...")
939 # await self.controller.connect_current()
Adam Israel88a49632018-04-10 13:04:57 -0600940 # await self.controller.connect(
941 # endpoint=self.endpoint,
942 # username=self.user,
943 # cacert=cacert,
944 # )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500945 self.log.fatal("VCA credentials not configured.")
946
947 self.authenticated = True
948 self.log.debug("JujuApi: Logged into controller")
949
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500950 async def logout(self):
951 """Logout of the Juju controller."""
952 if not self.authenticated:
953 return
954
955 try:
956 if self.default_model:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400957 self.log.debug("Disconnecting model {}".format(
958 self.default_model
959 ))
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500960 await self.default_model.disconnect()
Adam Israelfc511ed2018-09-21 14:20:55 +0200961 self.refcount['model'] -= 1
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500962 self.default_model = None
963
964 for model in self.models:
965 await self.models[model].disconnect()
Adam Israelfc511ed2018-09-21 14:20:55 +0200966 self.refcount['model'] -= 1
967 self.models[model] = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500968
969 if self.controller:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400970 self.log.debug("Disconnecting controller {}".format(
971 self.controller
972 ))
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500973 await self.controller.disconnect()
Adam Israelfc511ed2018-09-21 14:20:55 +0200974 self.refcount['controller'] -= 1
Adam Israel5e08a0e2018-09-06 19:22:47 -0400975 self.controller = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500976
977 self.authenticated = False
Adam Israelfc511ed2018-09-21 14:20:55 +0200978
979 self.log.debug(self.refcount)
980
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500981 except Exception as e:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400982 self.log.fatal(
983 "Fatal error logging out of Juju Controller: {}".format(e)
984 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500985 raise e
986
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500987 # async def remove_application(self, name):
988 # """Remove the application."""
989 # if not self.authenticated:
990 # await self.login()
991 #
992 # app = await self.get_application(name)
993 # if app:
994 # self.log.debug("JujuApi: Destroying application {}".format(
995 # name,
996 # ))
997 #
998 # await app.destroy()
999
1000 async def remove_relation(self, a, b):
1001 """
1002 Remove a relation between two application endpoints
1003
1004 :param a An application endpoint
1005 :param b An application endpoint
1006 """
1007 if not self.authenticated:
1008 await self.login()
1009
1010 m = await self.get_model()
1011 try:
1012 m.remove_relation(a, b)
1013 finally:
1014 await m.disconnect()
1015
1016 async def resolve_error(self, application=None):
1017 """Resolve units in error state."""
1018 if not self.authenticated:
1019 await self.login()
1020
1021 app = await self.get_application(self.default_model, application)
1022 if app:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001023 self.log.debug(
1024 "JujuApi: Resolving errors for application {}".format(
1025 application,
1026 )
1027 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001028
1029 for unit in app.units:
1030 app.resolved(retry=True)
1031
1032 async def run_action(self, application, action_name, **params):
1033 """Execute an action and return an Action object."""
1034 if not self.authenticated:
1035 await self.login()
1036 result = {
1037 'status': '',
1038 'action': {
1039 'tag': None,
1040 'results': None,
1041 }
1042 }
1043 app = await self.get_application(self.default_model, application)
1044 if app:
1045 # We currently only have one unit per application
1046 # so use the first unit available.
1047 unit = app.units[0]
1048
Adam Israel5e08a0e2018-09-06 19:22:47 -04001049 self.log.debug(
1050 "JujuApi: Running Action {} against Application {}".format(
1051 action_name,
1052 application,
1053 )
1054 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001055
1056 action = await unit.run_action(action_name, **params)
1057
1058 # Wait for the action to complete
1059 await action.wait()
1060
1061 result['status'] = action.status
1062 result['action']['tag'] = action.data['id']
1063 result['action']['results'] = action.results
1064
1065 return result
1066
Adam Israelb5214512018-05-03 10:00:04 -04001067 async def set_config(self, model_name, application, config):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001068 """Apply a configuration to the application."""
1069 if not self.authenticated:
1070 await self.login()
1071
Adam Israelb5214512018-05-03 10:00:04 -04001072 app = await self.get_application(model_name, application)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001073 if app:
1074 self.log.debug("JujuApi: Setting config for Application {}".format(
1075 application,
1076 ))
1077 await app.set_config(config)
1078
1079 # Verify the config is set
1080 newconf = await app.get_config()
1081 for key in 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]))
1084
Adam Israelb5214512018-05-03 10:00:04 -04001085 # 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()
1089 #
1090 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1091 # parameter,
1092 # value,
1093 # application,
1094 # ))
1095 # return await self.apply_config(
1096 # {parameter: value},
1097 # application=application,
1098 # )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001099
Adam Israel5e08a0e2018-09-06 19:22:47 -04001100 async def wait_for_application(self, model_name, application_name,
1101 timeout=300):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001102 """Wait for an application to become active."""
1103 if not self.authenticated:
1104 await self.login()
1105
Adam Israel5e08a0e2018-09-06 19:22:47 -04001106 # 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)
1110
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)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001114 if app:
1115 self.log.debug(
1116 "JujuApi: Waiting {} seconds for Application {}".format(
1117 timeout,
Adam Israel5e08a0e2018-09-06 19:22:47 -04001118 application_name,
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001119 )
1120 )
1121
Adam Israel5e08a0e2018-09-06 19:22:47 -04001122 await model.block_until(
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001123 lambda: all(
Adam Israel5e08a0e2018-09-06 19:22:47 -04001124 unit.agent_status == 'idle' and unit.workload_status in
1125 ['active', 'unknown'] for unit in app.units
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001126 ),
1127 timeout=timeout
1128 )