blob: 9441f4a646a190b6d3b97ae9a81a3a36bd47043c [file] [log] [blame]
Adam Israel0cd1c022019-09-03 18:26:08 -04001# Copyright 2019 Canonical Ltd.
2#
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
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
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.
14
Adam Israel5e08a0e2018-09-06 19:22:47 -040015import asyncio
Adam Israel19c5cfc2019-10-03 12:35:38 -040016import base64
17import binascii
Adam Israelc3e6c2e2018-03-01 09:31:50 -050018import logging
19import os
20import os.path
21import re
Adam Israelfa329072018-09-14 11:26:13 -040022import shlex
Adam Israelc3e6c2e2018-03-01 09:31:50 -050023import ssl
Adam Israelfa329072018-09-14 11:26:13 -040024import subprocess
Adam Israelc3e6c2e2018-03-01 09:31:50 -050025import sys
Adam Israel5e08a0e2018-09-06 19:22:47 -040026# import time
Adam Israel19c5cfc2019-10-03 12:35:38 -040027import n2vc.exceptions
Adam Israel0cd1c022019-09-03 18:26:08 -040028from n2vc.provisioner import SSHProvisioner
Adam Israelc3e6c2e2018-03-01 09:31:50 -050029
30# FIXME: this should load the juju inside or modules without having to
31# explicitly install it. Check why it's not working.
32# Load our subtree of the juju library
Adam Israel19c5cfc2019-10-03 12:35:38 -040033# path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
34# path = os.path.join(path, "modules/libjuju/")
35# if path not in sys.path:
36# sys.path.insert(1, path)
Adam Israelc3e6c2e2018-03-01 09:31:50 -050037
Adam Israel0cd1c022019-09-03 18:26:08 -040038from juju.client import client
Adam Israelc3e6c2e2018-03-01 09:31:50 -050039from juju.controller import Controller
Adam Israel5e08a0e2018-09-06 19:22:47 -040040from juju.model import ModelObserver
Adam Israel6d84dbd2019-03-08 18:33:35 -050041from juju.errors import JujuAPIError, JujuError
Adam Israelc3e6c2e2018-03-01 09:31:50 -050042
Adam Israel0cd1c022019-09-03 18:26:08 -040043
Adam Israelc3e6c2e2018-03-01 09:31:50 -050044# We might need this to connect to the websocket securely, but test and verify.
45try:
46 ssl._create_default_https_context = ssl._create_unverified_context
47except AttributeError:
48 # Legacy Python doesn't verify by default (see pep-0476)
49 # https://www.python.org/dev/peps/pep-0476/
50 pass
51
52
53# Custom exceptions
Adam Israel0cd1c022019-09-03 18:26:08 -040054# Deprecated. Please use n2vc.exceptions namespace.
Adam Israelc3e6c2e2018-03-01 09:31:50 -050055class JujuCharmNotFound(Exception):
56 """The Charm can't be found or is not readable."""
57
58
59class JujuApplicationExists(Exception):
60 """The Application already exists."""
61
Adam Israelb5214512018-05-03 10:00:04 -040062
Adam Israel88a49632018-04-10 13:04:57 -060063class N2VCPrimitiveExecutionFailed(Exception):
64 """Something failed while attempting to execute a primitive."""
65
Adam Israelc3e6c2e2018-03-01 09:31:50 -050066
Adam Israel6d84dbd2019-03-08 18:33:35 -050067class NetworkServiceDoesNotExist(Exception):
68 """The Network Service being acted against does not exist."""
69
70
Adam Israel32a15192019-06-24 11:44:47 -040071class PrimitiveDoesNotExist(Exception):
72 """The Primitive being executed does not exist."""
73
Adam Israel0cd1c022019-09-03 18:26:08 -040074
Adam Israelc3e6c2e2018-03-01 09:31:50 -050075# Quiet the debug logging
76logging.getLogger('websockets.protocol').setLevel(logging.INFO)
77logging.getLogger('juju.client.connection').setLevel(logging.WARN)
78logging.getLogger('juju.model').setLevel(logging.WARN)
79logging.getLogger('juju.machine').setLevel(logging.WARN)
80
Adam Israelb5214512018-05-03 10:00:04 -040081
Adam Israelc3e6c2e2018-03-01 09:31:50 -050082class VCAMonitor(ModelObserver):
83 """Monitor state changes within the Juju Model."""
Adam Israelc3e6c2e2018-03-01 09:31:50 -050084 log = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -050085
Adam Israel28a43c02018-04-23 16:04:54 -040086 def __init__(self, ns_name):
Adam Israelc3e6c2e2018-03-01 09:31:50 -050087 self.log = logging.getLogger(__name__)
88
89 self.ns_name = ns_name
Adam Israeld420a8b2019-04-09 16:07:53 -040090 self.applications = {}
Adam Israel28a43c02018-04-23 16:04:54 -040091
92 def AddApplication(self, application_name, callback, *callback_args):
93 if application_name not in self.applications:
94 self.applications[application_name] = {
95 'callback': callback,
96 'callback_args': callback_args
97 }
98
99 def RemoveApplication(self, application_name):
100 if application_name in self.applications:
101 del self.applications[application_name]
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500102
103 async def on_change(self, delta, old, new, model):
104 """React to changes in the Juju model."""
105
106 if delta.entity == "unit":
Adam Israel28a43c02018-04-23 16:04:54 -0400107 # Ignore change events from other applications
108 if delta.data['application'] not in self.applications.keys():
109 return
110
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500111 try:
Adam Israel28a43c02018-04-23 16:04:54 -0400112
113 application_name = delta.data['application']
114
115 callback = self.applications[application_name]['callback']
Adam Israel5e08a0e2018-09-06 19:22:47 -0400116 callback_args = \
117 self.applications[application_name]['callback_args']
Adam Israel28a43c02018-04-23 16:04:54 -0400118
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500119 if old and new:
Adam Israelfc511ed2018-09-21 14:20:55 +0200120 # Fire off a callback with the application state
121 if callback:
122 callback(
123 self.ns_name,
124 delta.data['application'],
125 new.workload_status,
126 new.workload_status_message,
127 *callback_args)
Adam Israel28a43c02018-04-23 16:04:54 -0400128
129 if old and not new:
130 # This is a charm being removed
131 if callback:
132 callback(
133 self.ns_name,
134 delta.data['application'],
135 "removed",
Adam Israel9562f432018-05-09 13:55:28 -0400136 "",
Adam Israel28a43c02018-04-23 16:04:54 -0400137 *callback_args)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500138 except Exception as e:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400139 self.log.debug("[1] notify_callback exception: {}".format(e))
140
Adam Israel88a49632018-04-10 13:04:57 -0600141 elif delta.entity == "action":
142 # TODO: Decide how we want to notify the user of actions
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500143
Adam Israel88a49632018-04-10 13:04:57 -0600144 # uuid = delta.data['id'] # The Action's unique id
145 # msg = delta.data['message'] # The output of the action
146 #
147 # if delta.data['status'] == "pending":
148 # # The action is queued
149 # pass
150 # elif delta.data['status'] == "completed""
151 # # The action was successful
152 # pass
153 # elif delta.data['status'] == "failed":
154 # # The action failed.
155 # pass
156
157 pass
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500158
159########
160# TODO
161#
162# Create unique models per network service
163# Document all public functions
164
Adam Israelb5214512018-05-03 10:00:04 -0400165
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500166class N2VC:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500167 def __init__(self,
168 log=None,
169 server='127.0.0.1',
170 port=17070,
171 user='admin',
172 secret=None,
Adam Israel5e08a0e2018-09-06 19:22:47 -0400173 artifacts=None,
174 loop=None,
Adam Israelb2a07f52019-04-25 17:17:05 -0400175 juju_public_key=None,
176 ca_cert=None,
Adam Israel0cd1c022019-09-03 18:26:08 -0400177 api_proxy=None
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500178 ):
179 """Initialize N2VC
Adam Israel0cd1c022019-09-03 18:26:08 -0400180
181 Initializes the N2VC object, allowing the caller to interoperate with the VCA.
182
183
Adam Israelb2a07f52019-04-25 17:17:05 -0400184 :param log obj: The logging object to log to
185 :param server str: The IP Address or Hostname of the Juju controller
186 :param port int: The port of the Juju Controller
187 :param user str: The Juju username to authenticate with
188 :param secret str: The Juju password to authenticate with
189 :param artifacts str: The directory where charms required by a vnfd are
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500190 stored.
Adam Israelb2a07f52019-04-25 17:17:05 -0400191 :param loop obj: The loop to use.
192 :param juju_public_key str: The contents of the Juju public SSH key
193 :param ca_cert str: The CA certificate to use to authenticate
Adam Israel0cd1c022019-09-03 18:26:08 -0400194 :param api_proxy str: The IP of the host machine
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500195
196 :Example:
Adam Israelb2a07f52019-04-25 17:17:05 -0400197 client = n2vc.vnf.N2VC(
198 log=log,
199 server='10.1.1.28',
200 port=17070,
201 user='admin',
202 secret='admin',
203 artifacts='/app/storage/myvnf/charms',
204 loop=loop,
205 juju_public_key='<contents of the juju public key>',
206 ca_cert='<contents of CA certificate>',
Adam Israel0cd1c022019-09-03 18:26:08 -0400207 api_proxy='192.168.1.155'
Adam Israelb2a07f52019-04-25 17:17:05 -0400208 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500209 """
210
Adam Israel5e08a0e2018-09-06 19:22:47 -0400211 # Initialize instance-level variables
212 self.api = None
213 self.log = None
214 self.controller = None
215 self.connecting = False
216 self.authenticated = False
Adam Israel0cd1c022019-09-03 18:26:08 -0400217 self.api_proxy = api_proxy
Adam Israel5e08a0e2018-09-06 19:22:47 -0400218
Adam Israel19c5cfc2019-10-03 12:35:38 -0400219 if log:
220 self.log = log
221 else:
222 self.log = logging.getLogger(__name__)
223
Adam Israelfc511ed2018-09-21 14:20:55 +0200224 # For debugging
225 self.refcount = {
226 'controller': 0,
227 'model': 0,
228 }
229
Adam Israel5e08a0e2018-09-06 19:22:47 -0400230 self.models = {}
Adam Israel5e08a0e2018-09-06 19:22:47 -0400231
232 # Model Observers
233 self.monitors = {}
234
235 # VCA config
236 self.hostname = ""
237 self.port = 17070
238 self.username = ""
239 self.secret = ""
Adam Israel19c5cfc2019-10-03 12:35:38 -0400240
Adam Israelb2a07f52019-04-25 17:17:05 -0400241 self.juju_public_key = juju_public_key
242 if juju_public_key:
243 self._create_juju_public_key(juju_public_key)
Adam Israel19c5cfc2019-10-03 12:35:38 -0400244 else:
245 self.juju_public_key = ''
Adam Israelb2a07f52019-04-25 17:17:05 -0400246
Adam Israel0cd1c022019-09-03 18:26:08 -0400247 # TODO: Verify ca_cert is valid before using. VCA will crash
248 # if the ca_cert isn't formatted correctly.
Adam Israel19c5cfc2019-10-03 12:35:38 -0400249 def base64_to_cacert(b64string):
250 """Convert the base64-encoded string containing the VCA CACERT.
Adam Israelb2a07f52019-04-25 17:17:05 -0400251
Adam Israel19c5cfc2019-10-03 12:35:38 -0400252 The input string....
253
254 """
255 try:
256 cacert = base64.b64decode(b64string).decode("utf-8")
257
258 cacert = re.sub(
259 r'\\n',
260 r'\n',
261 cacert,
262 )
263 except binascii.Error as e:
264 self.log.debug("Caught binascii.Error: {}".format(e))
265 raise n2vc.exceptions.InvalidCACertificate("Invalid CA Certificate")
266
267 return cacert
268
tiernof5b4b202019-11-11 17:45:17 +0000269 self.ca_cert = None
270 if ca_cert:
271 self.ca_cert = base64_to_cacert(ca_cert)
Adam Israel19c5cfc2019-10-03 12:35:38 -0400272
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500273
274 # Quiet websocket traffic
275 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
276 logging.getLogger('juju.client.connection').setLevel(logging.WARN)
277 logging.getLogger('model').setLevel(logging.WARN)
278 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
279
280 self.log.debug('JujuApi: instantiated')
281
282 self.server = server
283 self.port = port
284
285 self.secret = secret
286 if user.startswith('user-'):
287 self.user = user
288 else:
289 self.user = 'user-{}'.format(user)
290
291 self.endpoint = '%s:%d' % (server, int(port))
292
293 self.artifacts = artifacts
294
Adam Israel5e08a0e2018-09-06 19:22:47 -0400295 self.loop = loop or asyncio.get_event_loop()
296
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500297 def __del__(self):
298 """Close any open connections."""
299 yield self.logout()
300
Adam Israelb2a07f52019-04-25 17:17:05 -0400301 def _create_juju_public_key(self, public_key):
302 """Recreate the Juju public key on disk.
303
304 Certain libjuju commands expect to be run from the same machine as Juju
305 is bootstrapped to. This method will write the public key to disk in
306 that location: ~/.local/share/juju/ssh/juju_id_rsa.pub
307 """
Adam Israele3a05f82019-04-26 13:12:47 -0400308 # Make sure that we have a public key before writing to disk
Adam Israelb2a07f52019-04-25 17:17:05 -0400309 if public_key is None or len(public_key) == 0:
Adam Israele3a05f82019-04-26 13:12:47 -0400310 if 'OSM_VCA_PUBKEY' in os.environ:
311 public_key = os.getenv('OSM_VCA_PUBKEY', '')
312 if len(public_key == 0):
313 return
314 else:
315 return
316
Adam Israelb2a07f52019-04-25 17:17:05 -0400317 path = "{}/.local/share/juju/ssh".format(
318 os.path.expanduser('~'),
319 )
320 if not os.path.exists(path):
321 os.makedirs(path)
322
323 with open('{}/juju_id_rsa.pub'.format(path), 'w') as f:
324 f.write(public_key)
325
Adam Israel5e08a0e2018-09-06 19:22:47 -0400326 def notify_callback(self, model_name, application_name, status, message,
327 callback=None, *callback_args):
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500328 try:
329 if callback:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400330 callback(
331 model_name,
332 application_name,
333 status, message,
334 *callback_args,
335 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500336 except Exception as e:
337 self.log.error("[0] notify_callback exception {}".format(e))
Adam Israel88a49632018-04-10 13:04:57 -0600338 raise e
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500339 return True
340
341 # Public methods
Adam Israel85a4b212018-11-29 20:30:24 -0500342 async def Relate(self, model_name, vnfd):
Adam Israel136186e2018-09-14 12:01:12 -0400343 """Create a relation between the charm-enabled VDUs in a VNF.
344
345 The Relation mapping has two parts: the id of the vdu owning the endpoint, and the name of the endpoint.
346
347 vdu:
348 ...
Adam Israelbc0daf82019-06-12 17:08:36 -0400349 vca-relationships:
Adam Israel47998e02019-06-03 11:21:27 -0400350 relation:
351 - provides: dataVM:db
352 requires: mgmtVM:app
Adam Israel136186e2018-09-14 12:01:12 -0400353
354 This tells N2VC that the charm referred to by the dataVM vdu offers a relation named 'db', and the mgmtVM vdu has an 'app' endpoint that should be connected to a database.
355
356 :param str ns_name: The name of the network service.
357 :param dict vnfd: The parsed yaml VNF descriptor.
358 """
359
360 # Currently, the call to Relate() is made automatically after the
361 # deployment of each charm; if the relation depends on a charm that
362 # hasn't been deployed yet, the call will fail silently. This will
363 # prevent an API breakage, with the intent of making this an explicitly
364 # required call in a more object-oriented refactor of the N2VC API.
365
366 configs = []
367 vnf_config = vnfd.get("vnf-configuration")
368 if vnf_config:
369 juju = vnf_config['juju']
370 if juju:
371 configs.append(vnf_config)
372
373 for vdu in vnfd['vdu']:
374 vdu_config = vdu.get('vdu-configuration')
375 if vdu_config:
376 juju = vdu_config['juju']
377 if juju:
378 configs.append(vdu_config)
379
380 def _get_application_name(name):
381 """Get the application name that's mapped to a vnf/vdu."""
382 vnf_member_index = 0
383 vnf_name = vnfd['name']
384
385 for vdu in vnfd.get('vdu'):
386 # Compare the named portion of the relation to the vdu's id
387 if vdu['id'] == name:
388 application_name = self.FormatApplicationName(
Adam Israel85a4b212018-11-29 20:30:24 -0500389 model_name,
Adam Israel136186e2018-09-14 12:01:12 -0400390 vnf_name,
391 str(vnf_member_index),
392 )
393 return application_name
394 else:
395 vnf_member_index += 1
396
397 return None
398
399 # Loop through relations
400 for cfg in configs:
401 if 'juju' in cfg:
Adam Israelc92163f2019-05-27 08:39:19 -0400402 juju = cfg['juju']
Adam Israelbc0daf82019-06-12 17:08:36 -0400403 if 'vca-relationships' in juju and 'relation' in juju['vca-relationships']:
404 for rel in juju['vca-relationships']['relation']:
Adam Israel136186e2018-09-14 12:01:12 -0400405 try:
406
407 # get the application name for the provides
408 (name, endpoint) = rel['provides'].split(':')
409 application_name = _get_application_name(name)
410
411 provides = "{}:{}".format(
412 application_name,
413 endpoint
414 )
415
416 # get the application name for thr requires
417 (name, endpoint) = rel['requires'].split(':')
418 application_name = _get_application_name(name)
419
420 requires = "{}:{}".format(
421 application_name,
422 endpoint
423 )
424 self.log.debug("Relation: {} <-> {}".format(
425 provides,
426 requires
427 ))
428 await self.add_relation(
Adam Israel85a4b212018-11-29 20:30:24 -0500429 model_name,
Adam Israel136186e2018-09-14 12:01:12 -0400430 provides,
431 requires,
432 )
433 except Exception as e:
434 self.log.debug("Exception: {}".format(e))
435
436 return
437
Adam Israel5e08a0e2018-09-06 19:22:47 -0400438 async def DeployCharms(self, model_name, application_name, vnfd,
439 charm_path, params={}, machine_spec={},
440 callback=None, *callback_args):
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500441 """Deploy one or more charms associated with a VNF.
442
443 Deploy the charm(s) referenced in a VNF Descriptor.
444
Adam Israel85a4b212018-11-29 20:30:24 -0500445 :param str model_name: The name or unique id of the network service.
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500446 :param str application_name: The name of the application
447 :param dict vnfd: The name of the application
448 :param str charm_path: The path to the Juju charm
449 :param dict params: A dictionary of runtime parameters
450 Examples::
451 {
Adam Israel88a49632018-04-10 13:04:57 -0600452 'rw_mgmt_ip': '1.2.3.4',
453 # Pass the initial-config-primitives section of the vnf or vdu
454 'initial-config-primitives': {...}
tierno1afb30a2018-12-21 13:42:43 +0000455 'user_values': dictionary with the day-1 parameters provided at instantiation time. It will replace values
456 inside < >. rw_mgmt_ip will be included here also
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500457 }
Adam Israel5e08a0e2018-09-06 19:22:47 -0400458 :param dict machine_spec: A dictionary describing the machine to
459 install to
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500460 Examples::
461 {
462 'hostname': '1.2.3.4',
463 'username': 'ubuntu',
464 }
465 :param obj callback: A callback function to receive status changes.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400466 :param tuple callback_args: A list of arguments to be passed to the
467 callback
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500468 """
469
470 ########################################################
471 # Verify the path to the charm exists and is readable. #
472 ########################################################
473 if not os.path.exists(charm_path):
474 self.log.debug("Charm path doesn't exist: {}".format(charm_path))
Adam Israel5e08a0e2018-09-06 19:22:47 -0400475 self.notify_callback(
476 model_name,
477 application_name,
Dominik Fleischmannd909b072019-11-28 16:27:36 +0100478 "error",
Adam Israel5e08a0e2018-09-06 19:22:47 -0400479 "failed",
480 callback,
481 *callback_args,
482 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500483 raise JujuCharmNotFound("No artifacts configured.")
484
485 ################################
486 # Login to the Juju controller #
487 ################################
488 if not self.authenticated:
489 self.log.debug("Authenticating with Juju")
490 await self.login()
491
492 ##########################################
493 # Get the model for this network service #
494 ##########################################
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500495 model = await self.get_model(model_name)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500496
497 ########################################
498 # Verify the application doesn't exist #
499 ########################################
500 app = await self.get_application(model, application_name)
501 if app:
Adam Israel42d88e62018-07-16 14:18:41 -0400502 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name, model_name))
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500503
Adam Israel28a43c02018-04-23 16:04:54 -0400504 ################################################################
505 # Register this application with the model-level event monitor #
506 ################################################################
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500507 if callback:
Adam Israel04eee1f2019-04-29 14:59:45 -0400508 self.log.debug("JujuApi: Registering callback for {}".format(
Adam Israel28a43c02018-04-23 16:04:54 -0400509 application_name,
Adam Israel04eee1f2019-04-29 14:59:45 -0400510 ))
511 await self.Subscribe(model_name, application_name, callback, *callback_args)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500512
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500513 #######################################
514 # Get the initial charm configuration #
515 #######################################
516
517 rw_mgmt_ip = None
518 if 'rw_mgmt_ip' in params:
519 rw_mgmt_ip = params['rw_mgmt_ip']
520
Adam Israel5afe0542018-08-08 12:54:55 -0400521 if 'initial-config-primitive' not in params:
522 params['initial-config-primitive'] = {}
523
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500524 initial_config = self._get_config_from_dict(
Adam Israel88a49632018-04-10 13:04:57 -0600525 params['initial-config-primitive'],
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500526 {'<rw_mgmt_ip>': rw_mgmt_ip}
527 )
528
Adam Israel0cd1c022019-09-03 18:26:08 -0400529 ########################################################
530 # Check for specific machine placement (native charms) #
531 ########################################################
532 to = ""
533 series = "xenial"
534
535 if machine_spec.keys():
536 if all(k in machine_spec for k in ['hostname', 'username']):
537
538 # Allow series to be derived from the native charm
539 series = None
540
541 self.log.debug("Provisioning manual machine {}@{}".format(
542 machine_spec['username'],
543 machine_spec['hostname'],
544 ))
545
546 """Native Charm support
547
548 Taking a bare VM (assumed to be an Ubuntu cloud image),
549 the provisioning process will:
550 - Create an ubuntu user w/sudo access
551 - Detect hardware
552 - Detect architecture
553 - Download and install Juju agent from controller
554 - Enable Juju agent
555 - Add an iptables rule to route traffic to the API proxy
556 """
557
558 to = await self.provision_machine(
559 model_name=model_name,
560 username=machine_spec['username'],
561 hostname=machine_spec['hostname'],
562 private_key_path=self.GetPrivateKeyPath(),
563 )
564 self.log.debug("Provisioned machine id {}".format(to))
565
566 # TODO: If to is none, raise an exception
567
568 # The native charm won't have the sshproxy layer, typically, but LCM uses the config primitive
569 # to interpret what the values are. That's a gap to fill.
570
571 """
572 The ssh-* config parameters are unique to the sshproxy layer,
573 which most native charms will not be aware of.
574
575 Setting invalid config parameters will cause the deployment to
576 fail.
577
578 For the moment, we will strip the ssh-* parameters from native
579 charms, until the feature gap is addressed in the information
580 model.
581 """
582
583 # Native charms don't include the ssh-* config values, so strip them
584 # from the initial_config, otherwise the deploy will raise an error.
585 # self.log.debug("Removing ssh-* from initial-config")
586 for k in ['ssh-hostname', 'ssh-username', 'ssh-password']:
587 if k in initial_config:
588 self.log.debug("Removing parameter {}".format(k))
589 del initial_config[k]
590
591 self.log.debug("JujuApi: Deploying charm ({}/{}) from {} to {}".format(
Adam Israel85a4b212018-11-29 20:30:24 -0500592 model_name,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500593 application_name,
594 charm_path,
Adam Israel0cd1c022019-09-03 18:26:08 -0400595 to,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500596 ))
597
598 ########################################################
599 # Deploy the charm and apply the initial configuration #
600 ########################################################
601 app = await model.deploy(
Adam Israel88a49632018-04-10 13:04:57 -0600602 # We expect charm_path to be either the path to the charm on disk
603 # or in the format of cs:series/name
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500604 charm_path,
Adam Israel88a49632018-04-10 13:04:57 -0600605 # This is the formatted, unique name for this charm
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500606 application_name=application_name,
Adam Israel88a49632018-04-10 13:04:57 -0600607 # Proxy charms should use the current LTS. This will need to be
608 # changed for native charms.
Adam Israel0cd1c022019-09-03 18:26:08 -0400609 series=series,
Adam Israel88a49632018-04-10 13:04:57 -0600610 # Apply the initial 'config' primitive during deployment
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500611 config=initial_config,
Adam Israelfa329072018-09-14 11:26:13 -0400612 # Where to deploy the charm to.
613 to=to,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500614 )
Adam Israel0cd1c022019-09-03 18:26:08 -0400615
Adam Israel38bf1642019-05-31 09:59:52 -0400616 #############################
617 # Map the vdu id<->app name #
618 #############################
619 try:
620 await self.Relate(model_name, vnfd)
621 except KeyError as ex:
622 # We don't currently support relations between NS and VNF/VDU charms
623 self.log.warn("[N2VC] Relations not supported: {}".format(ex))
624 except Exception as ex:
625 # This may happen if not all of the charms needed by the relation
626 # are ready. We can safely ignore this, because Relate will be
627 # retried when the endpoint of the relation is deployed.
628 self.log.warn("[N2VC] Relations not ready")
Adam Israel136186e2018-09-14 12:01:12 -0400629
Adam Israel88a49632018-04-10 13:04:57 -0600630 # #######################################
631 # # Execute initial config primitive(s) #
632 # #######################################
Adam Israelcf253202018-10-31 16:29:09 -0700633 uuids = await self.ExecuteInitialPrimitives(
Adam Israel5e08a0e2018-09-06 19:22:47 -0400634 model_name,
635 application_name,
636 params,
637 )
Adam Israelcf253202018-10-31 16:29:09 -0700638 return uuids
Adam Israel5e08a0e2018-09-06 19:22:47 -0400639
640 # primitives = {}
641 #
642 # # Build a sequential list of the primitives to execute
643 # for primitive in params['initial-config-primitive']:
644 # try:
645 # if primitive['name'] == 'config':
646 # # This is applied when the Application is deployed
647 # pass
648 # else:
649 # seq = primitive['seq']
650 #
651 # params = {}
652 # if 'parameter' in primitive:
653 # params = primitive['parameter']
654 #
655 # primitives[seq] = {
656 # 'name': primitive['name'],
657 # 'parameters': self._map_primitive_parameters(
658 # params,
659 # {'<rw_mgmt_ip>': rw_mgmt_ip}
660 # ),
661 # }
662 #
663 # for primitive in sorted(primitives):
664 # await self.ExecutePrimitive(
665 # model_name,
666 # application_name,
667 # primitives[primitive]['name'],
668 # callback,
669 # callback_args,
670 # **primitives[primitive]['parameters'],
671 # )
672 # except N2VCPrimitiveExecutionFailed as e:
673 # self.log.debug(
674 # "[N2VC] Exception executing primitive: {}".format(e)
675 # )
676 # raise
677
678 async def GetPrimitiveStatus(self, model_name, uuid):
679 """Get the status of an executed Primitive.
680
681 The status of an executed Primitive will be one of three values:
682 - completed
683 - failed
684 - running
685 """
686 status = None
687 try:
688 if not self.authenticated:
689 await self.login()
690
Adam Israel5e08a0e2018-09-06 19:22:47 -0400691 model = await self.get_model(model_name)
692
693 results = await model.get_action_status(uuid)
694
695 if uuid in results:
696 status = results[uuid]
697
698 except Exception as e:
699 self.log.debug(
700 "Caught exception while getting primitive status: {}".format(e)
701 )
702 raise N2VCPrimitiveExecutionFailed(e)
703
704 return status
705
706 async def GetPrimitiveOutput(self, model_name, uuid):
707 """Get the output of an executed Primitive.
708
709 Note: this only returns output for a successfully executed primitive.
710 """
711 results = None
712 try:
713 if not self.authenticated:
714 await self.login()
715
Adam Israel5e08a0e2018-09-06 19:22:47 -0400716 model = await self.get_model(model_name)
717 results = await model.get_action_output(uuid, 60)
718 except Exception as e:
719 self.log.debug(
720 "Caught exception while getting primitive status: {}".format(e)
721 )
722 raise N2VCPrimitiveExecutionFailed(e)
723
724 return results
725
Adam Israelfa329072018-09-14 11:26:13 -0400726 # async def ProvisionMachine(self, model_name, hostname, username):
727 # """Provision machine for usage with Juju.
728 #
729 # Provisions a previously instantiated machine for use with Juju.
730 # """
731 # try:
732 # if not self.authenticated:
733 # await self.login()
734 #
735 # # FIXME: This is hard-coded until model-per-ns is added
736 # model_name = 'default'
737 #
738 # model = await self.get_model(model_name)
739 # model.add_machine(spec={})
740 #
741 # machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
742 # "ubuntu",
743 # host['address'],
744 # private_key_path,
745 # ))
746 # return machine.id
747 #
748 # except Exception as e:
749 # self.log.debug(
750 # "Caught exception while getting primitive status: {}".format(e)
751 # )
752 # raise N2VCPrimitiveExecutionFailed(e)
753
754 def GetPrivateKeyPath(self):
755 homedir = os.environ['HOME']
756 sshdir = "{}/.ssh".format(homedir)
757 private_key_path = "{}/id_n2vc_rsa".format(sshdir)
758 return private_key_path
759
760 async def GetPublicKey(self):
761 """Get the N2VC SSH public key.abs
762
763 Returns the SSH public key, to be injected into virtual machines to
764 be managed by the VCA.
765
766 The first time this is run, a ssh keypair will be created. The public
767 key is injected into a VM so that we can provision the machine with
768 Juju, after which Juju will communicate with the VM directly via the
769 juju agent.
770 """
771 public_key = ""
772
773 # Find the path to where we expect our key to live.
774 homedir = os.environ['HOME']
775 sshdir = "{}/.ssh".format(homedir)
776 if not os.path.exists(sshdir):
777 os.mkdir(sshdir)
778
779 private_key_path = "{}/id_n2vc_rsa".format(sshdir)
780 public_key_path = "{}.pub".format(private_key_path)
781
782 # If we don't have a key generated, generate it.
783 if not os.path.exists(private_key_path):
784 cmd = "ssh-keygen -t {} -b {} -N '' -f {}".format(
785 "rsa",
786 "4096",
787 private_key_path
788 )
789 subprocess.check_output(shlex.split(cmd))
790
791 # Read the public key
792 with open(public_key_path, "r") as f:
793 public_key = f.readline()
794
795 return public_key
796
Adam Israel5e08a0e2018-09-06 19:22:47 -0400797 async def ExecuteInitialPrimitives(self, model_name, application_name,
798 params, callback=None, *callback_args):
799 """Execute multiple primitives.
800
801 Execute multiple primitives as declared in initial-config-primitive.
802 This is useful in cases where the primitives initially failed -- for
803 example, if the charm is a proxy but the proxy hasn't been configured
804 yet.
805 """
806 uuids = []
Adam Israel88a49632018-04-10 13:04:57 -0600807 primitives = {}
808
809 # Build a sequential list of the primitives to execute
810 for primitive in params['initial-config-primitive']:
811 try:
812 if primitive['name'] == 'config':
Adam Israel88a49632018-04-10 13:04:57 -0600813 pass
814 else:
Adam Israel88a49632018-04-10 13:04:57 -0600815 seq = primitive['seq']
816
tierno1afb30a2018-12-21 13:42:43 +0000817 params_ = {}
Adam Israel42d88e62018-07-16 14:18:41 -0400818 if 'parameter' in primitive:
tierno1afb30a2018-12-21 13:42:43 +0000819 params_ = primitive['parameter']
820
821 user_values = params.get("user_values", {})
822 if 'rw_mgmt_ip' not in user_values:
823 user_values['rw_mgmt_ip'] = None
824 # just for backward compatibility, because it will be provided always by modern version of LCM
Adam Israel42d88e62018-07-16 14:18:41 -0400825
Adam Israel88a49632018-04-10 13:04:57 -0600826 primitives[seq] = {
827 'name': primitive['name'],
828 'parameters': self._map_primitive_parameters(
tierno1afb30a2018-12-21 13:42:43 +0000829 params_,
830 user_values
Adam Israel88a49632018-04-10 13:04:57 -0600831 ),
832 }
833
834 for primitive in sorted(primitives):
Adam Israel32a15192019-06-24 11:44:47 -0400835 try:
836 # self.log.debug("Queuing action {}".format(primitives[primitive]['name']))
837 uuids.append(
838 await self.ExecutePrimitive(
839 model_name,
840 application_name,
841 primitives[primitive]['name'],
842 callback,
843 callback_args,
844 **primitives[primitive]['parameters'],
845 )
Adam Israel5e08a0e2018-09-06 19:22:47 -0400846 )
Adam Israel32a15192019-06-24 11:44:47 -0400847 except PrimitiveDoesNotExist as e:
848 self.log.debug("Ignoring exception PrimitiveDoesNotExist: {}".format(e))
849 pass
850 except Exception as e:
851 self.log.debug("XXXXXXXXXXXXXXXXXXXXXXXXX Unexpected exception: {}".format(e))
852 raise e
853
Adam Israel88a49632018-04-10 13:04:57 -0600854 except N2VCPrimitiveExecutionFailed as e:
Adam Israel7d871fb2018-07-17 12:17:06 -0400855 self.log.debug(
Adam Israel88a49632018-04-10 13:04:57 -0600856 "[N2VC] Exception executing primitive: {}".format(e)
857 )
858 raise
Adam Israel5e08a0e2018-09-06 19:22:47 -0400859 return uuids
Adam Israel88a49632018-04-10 13:04:57 -0600860
Adam Israel5e08a0e2018-09-06 19:22:47 -0400861 async def ExecutePrimitive(self, model_name, application_name, primitive,
862 callback, *callback_args, **params):
Adam Israelc9df96f2018-05-03 14:49:56 -0400863 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
Adam Israel6817f612018-04-13 08:41:43 -0600864
Adam Israelc9df96f2018-05-03 14:49:56 -0400865 Execute a primitive defined in the VNF descriptor.
866
Adam Israel85a4b212018-11-29 20:30:24 -0500867 :param str model_name: The name or unique id of the network service.
Adam Israelc9df96f2018-05-03 14:49:56 -0400868 :param str application_name: The name of the application
869 :param str primitive: The name of the primitive to execute.
870 :param obj callback: A callback function to receive status changes.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400871 :param tuple callback_args: A list of arguments to be passed to the
872 callback function.
873 :param dict params: A dictionary of key=value pairs representing the
874 primitive's parameters
Adam Israelc9df96f2018-05-03 14:49:56 -0400875 Examples::
876 {
877 'rw_mgmt_ip': '1.2.3.4',
878 # Pass the initial-config-primitives section of the vnf or vdu
879 'initial-config-primitives': {...}
880 }
Adam Israel6817f612018-04-13 08:41:43 -0600881 """
tierno1afb30a2018-12-21 13:42:43 +0000882 self.log.debug("Executing primitive={} params={}".format(primitive, params))
Adam Israel6817f612018-04-13 08:41:43 -0600883 uuid = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500884 try:
885 if not self.authenticated:
886 await self.login()
887
Adam Israel5e08a0e2018-09-06 19:22:47 -0400888 model = await self.get_model(model_name)
Adam Israelb5214512018-05-03 10:00:04 -0400889
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500890 if primitive == 'config':
891 # config is special, and expecting params to be a dictionary
Adam Israelb0943662018-08-02 15:32:00 -0400892 await self.set_config(
893 model,
894 application_name,
895 params['params'],
896 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500897 else:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500898 app = await self.get_application(model, application_name)
899 if app:
Adam Israel32a15192019-06-24 11:44:47 -0400900 # Does this primitive exist?
901 actions = await app.get_actions()
902
903 if primitive not in actions.keys():
904 raise PrimitiveDoesNotExist("Primitive {} does not exist".format(primitive))
905
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500906 # Run against the first (and probably only) unit in the app
907 unit = app.units[0]
908 if unit:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500909 action = await unit.run_action(primitive, **params)
Adam Israel6817f612018-04-13 08:41:43 -0600910 uuid = action.id
Adam Israel32a15192019-06-24 11:44:47 -0400911 except PrimitiveDoesNotExist as e:
912 # Catch and raise this exception if it's thrown from the inner block
913 raise e
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500914 except Exception as e:
Adam Israel32a15192019-06-24 11:44:47 -0400915 # An unexpected exception was caught
Adam Israelb0943662018-08-02 15:32:00 -0400916 self.log.debug(
917 "Caught exception while executing primitive: {}".format(e)
918 )
Adam Israel7d871fb2018-07-17 12:17:06 -0400919 raise N2VCPrimitiveExecutionFailed(e)
Adam Israel6817f612018-04-13 08:41:43 -0600920 return uuid
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500921
Adam Israel5e08a0e2018-09-06 19:22:47 -0400922 async def RemoveCharms(self, model_name, application_name, callback=None,
923 *callback_args):
Adam Israelc9df96f2018-05-03 14:49:56 -0400924 """Remove a charm from the VCA.
925
926 Remove a charm referenced in a VNF Descriptor.
927
928 :param str model_name: The name of the network service.
929 :param str application_name: The name of the application
930 :param obj callback: A callback function to receive status changes.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400931 :param tuple callback_args: A list of arguments to be passed to the
932 callback function.
Adam Israelc9df96f2018-05-03 14:49:56 -0400933 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500934 try:
935 if not self.authenticated:
936 await self.login()
937
938 model = await self.get_model(model_name)
939 app = await self.get_application(model, application_name)
940 if app:
Adam Israel28a43c02018-04-23 16:04:54 -0400941 # Remove this application from event monitoring
Adam Israel04eee1f2019-04-29 14:59:45 -0400942 await self.Unsubscribe(model_name, application_name)
Adam Israel28a43c02018-04-23 16:04:54 -0400943
944 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
Adam Israel5e08a0e2018-09-06 19:22:47 -0400945 self.log.debug(
946 "Removing the application {}".format(application_name)
947 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500948 await app.remove()
Adam Israel28a43c02018-04-23 16:04:54 -0400949
Adam Israel0cd1c022019-09-03 18:26:08 -0400950 # await self.disconnect_model(self.monitors[model_name])
Adam Israel85a4b212018-11-29 20:30:24 -0500951
Adam Israel5e08a0e2018-09-06 19:22:47 -0400952 self.notify_callback(
953 model_name,
954 application_name,
955 "removed",
Adam Israelc4f393e2019-03-19 16:33:30 -0400956 "Removing charm {}".format(application_name),
Adam Israel5e08a0e2018-09-06 19:22:47 -0400957 callback,
958 *callback_args,
959 )
Adam Israel28a43c02018-04-23 16:04:54 -0400960
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500961 except Exception as e:
962 print("Caught exception: {}".format(e))
Adam Israel88a49632018-04-10 13:04:57 -0600963 self.log.debug(e)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500964 raise e
965
Adam Israel6d84dbd2019-03-08 18:33:35 -0500966 async def CreateNetworkService(self, ns_uuid):
967 """Create a new Juju model for the Network Service.
968
969 Creates a new Model in the Juju Controller.
970
971 :param str ns_uuid: A unique id representing an instaance of a
972 Network Service.
973
974 :returns: True if the model was created. Raises JujuError on failure.
975 """
976 if not self.authenticated:
977 await self.login()
978
979 models = await self.controller.list_models()
980 if ns_uuid not in models:
Adam Israel19c5cfc2019-10-03 12:35:38 -0400981 # Get the new model
982 await self.get_model(ns_uuid)
Adam Israel7bf2f4d2019-03-15 15:28:47 -0400983
Adam Israel6d84dbd2019-03-08 18:33:35 -0500984 return True
985
986 async def DestroyNetworkService(self, ns_uuid):
987 """Destroy a Network Service.
988
989 Destroy the Network Service and any deployed charms.
990
991 :param ns_uuid The unique id of the Network Service
992
993 :returns: True if the model was created. Raises JujuError on failure.
994 """
995
996 # Do not delete the default model. The default model was used by all
997 # Network Services, prior to the implementation of a model per NS.
Adam Israelc4f393e2019-03-19 16:33:30 -0400998 if ns_uuid.lower() == "default":
Adam Israel6d84dbd2019-03-08 18:33:35 -0500999 return False
1000
1001 if not self.authenticated:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001002 await self.login()
1003
Adam Israel0cd1c022019-09-03 18:26:08 -04001004 models = await self.controller.list_models()
1005 if ns_uuid in models:
1006 model = await self.controller.get_model(ns_uuid)
1007
1008 for application in model.applications:
1009 app = model.applications[application]
1010
1011 await self.RemoveCharms(ns_uuid, application)
1012
1013 self.log.debug("Unsubscribing Watcher for {}".format(application))
1014 await self.Unsubscribe(ns_uuid, application)
1015
1016 self.log.debug("Waiting for application to terminate")
1017 timeout = 30
1018 try:
1019 await model.block_until(
1020 lambda: all(
1021 unit.workload_status in ['terminated'] for unit in app.units
1022 ),
1023 timeout=timeout
1024 )
1025 except Exception as e:
1026 self.log.debug("Timed out waiting for {} to terminate.".format(application))
1027
1028 for machine in model.machines:
1029 try:
1030 self.log.debug("Destroying machine {}".format(machine))
1031 await model.machines[machine].destroy(force=True)
1032 except JujuAPIError as e:
1033 if 'does not exist' in str(e):
1034 # Our cached model may be stale, because the machine
1035 # has already been removed. It's safe to continue.
1036 continue
1037 else:
1038 self.log.debug("Caught exception: {}".format(e))
1039 raise e
1040
Adam Israel6d84dbd2019-03-08 18:33:35 -05001041 # Disconnect from the Model
1042 if ns_uuid in self.models:
Adam Israel0cd1c022019-09-03 18:26:08 -04001043 self.log.debug("Disconnecting model {}".format(ns_uuid))
1044 # await self.disconnect_model(self.models[ns_uuid])
1045 await self.disconnect_model(ns_uuid)
Adam Israel6d84dbd2019-03-08 18:33:35 -05001046
1047 try:
Adam Israel0cd1c022019-09-03 18:26:08 -04001048 self.log.debug("Destroying model {}".format(ns_uuid))
Adam Israel6d84dbd2019-03-08 18:33:35 -05001049 await self.controller.destroy_models(ns_uuid)
Adam Israelc4f393e2019-03-19 16:33:30 -04001050 except JujuError:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001051 raise NetworkServiceDoesNotExist(
1052 "The Network Service '{}' does not exist".format(ns_uuid)
1053 )
1054
1055 return True
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001056
Adam Israelb5214512018-05-03 10:00:04 -04001057 async def GetMetrics(self, model_name, application_name):
1058 """Get the metrics collected by the VCA.
1059
Adam Israel85a4b212018-11-29 20:30:24 -05001060 :param model_name The name or unique id of the network service
Adam Israelb5214512018-05-03 10:00:04 -04001061 :param application_name The name of the application
1062 """
1063 metrics = {}
1064 model = await self.get_model(model_name)
1065 app = await self.get_application(model, application_name)
1066 if app:
1067 metrics = await app.get_metrics()
1068
1069 return metrics
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001070
Adam Israelfa329072018-09-14 11:26:13 -04001071 async def HasApplication(self, model_name, application_name):
1072 model = await self.get_model(model_name)
1073 app = await self.get_application(model, application_name)
1074 if app:
1075 return True
1076 return False
1077
Adam Israel04eee1f2019-04-29 14:59:45 -04001078 async def Subscribe(self, ns_name, application_name, callback, *callback_args):
1079 """Subscribe to callbacks for an application.
1080
1081 :param ns_name str: The name of the Network Service
1082 :param application_name str: The name of the application
1083 :param callback obj: The callback method
1084 :param callback_args list: The list of arguments to append to calls to
1085 the callback method
1086 """
1087 self.monitors[ns_name].AddApplication(
1088 application_name,
1089 callback,
1090 *callback_args
1091 )
1092
1093 async def Unsubscribe(self, ns_name, application_name):
1094 """Unsubscribe to callbacks for an application.
1095
1096 Unsubscribes the caller from notifications from a deployed application.
1097
1098 :param ns_name str: The name of the Network Service
1099 :param application_name str: The name of the application
1100 """
1101 self.monitors[ns_name].RemoveApplication(
1102 application_name,
1103 )
1104
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001105 # Non-public methods
Adam Israel136186e2018-09-14 12:01:12 -04001106 async def add_relation(self, model_name, relation1, relation2):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001107 """
1108 Add a relation between two application endpoints.
1109
Adam Israel85a4b212018-11-29 20:30:24 -05001110 :param str model_name: The name or unique id of the network service
1111 :param str relation1: '<application>[:<relation_name>]'
1112 :param str relation2: '<application>[:<relation_name>]'
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001113 """
Adam Israel136186e2018-09-14 12:01:12 -04001114
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001115 if not self.authenticated:
1116 await self.login()
1117
Adam Israel136186e2018-09-14 12:01:12 -04001118 m = await self.get_model(model_name)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001119 try:
Adam Israel136186e2018-09-14 12:01:12 -04001120 await m.add_relation(relation1, relation2)
1121 except JujuAPIError as e:
1122 # If one of the applications in the relationship doesn't exist,
1123 # or the relation has already been added, let the operation fail
1124 # silently.
1125 if 'not found' in e.message:
1126 return
1127 if 'already exists' in e.message:
1128 return
1129
1130 raise e
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001131
Adam Israelb5214512018-05-03 10:00:04 -04001132 # async def apply_config(self, config, application):
1133 # """Apply a configuration to the application."""
1134 # print("JujuApi: Applying configuration to {}.".format(
1135 # application
1136 # ))
1137 # return await self.set_config(application=application, config=config)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001138
1139 def _get_config_from_dict(self, config_primitive, values):
Adam Israel88a49632018-04-10 13:04:57 -06001140 """Transform the yang config primitive to dict.
1141
1142 Expected result:
1143
1144 config = {
1145 'config':
1146 }
1147 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001148 config = {}
1149 for primitive in config_primitive:
1150 if primitive['name'] == 'config':
Adam Israel88a49632018-04-10 13:04:57 -06001151 # config = self._map_primitive_parameters()
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001152 for parameter in primitive['parameter']:
1153 param = str(parameter['name'])
1154 if parameter['value'] == "<rw_mgmt_ip>":
1155 config[param] = str(values[parameter['value']])
1156 else:
1157 config[param] = str(parameter['value'])
1158
1159 return config
1160
tierno1afb30a2018-12-21 13:42:43 +00001161 def _map_primitive_parameters(self, parameters, user_values):
Adam Israel88a49632018-04-10 13:04:57 -06001162 params = {}
1163 for parameter in parameters:
1164 param = str(parameter['name'])
tierno1afb30a2018-12-21 13:42:43 +00001165 value = parameter.get('value')
1166
1167 # map parameters inside a < >; e.g. <rw_mgmt_ip>. with the provided user_values.
1168 # Must exist at user_values except if there is a default value
1169 if isinstance(value, str) and value.startswith("<") and value.endswith(">"):
1170 if parameter['value'][1:-1] in user_values:
1171 value = user_values[parameter['value'][1:-1]]
1172 elif 'default-value' in parameter:
1173 value = parameter['default-value']
1174 else:
1175 raise KeyError("parameter {}='{}' not supplied ".format(param, value))
Adam Israel5e08a0e2018-09-06 19:22:47 -04001176
Adam Israelbf793522018-11-20 13:54:13 -05001177 # If there's no value, use the default-value (if set)
tierno1afb30a2018-12-21 13:42:43 +00001178 if value is None and 'default-value' in parameter:
Adam Israelbf793522018-11-20 13:54:13 -05001179 value = parameter['default-value']
1180
Adam Israel5e08a0e2018-09-06 19:22:47 -04001181 # Typecast parameter value, if present
tierno1afb30a2018-12-21 13:42:43 +00001182 paramtype = "string"
1183 try:
1184 if 'data-type' in parameter:
1185 paramtype = str(parameter['data-type']).lower()
Adam Israel5e08a0e2018-09-06 19:22:47 -04001186
tierno1afb30a2018-12-21 13:42:43 +00001187 if paramtype == "integer":
1188 value = int(value)
1189 elif paramtype == "boolean":
1190 value = bool(value)
1191 else:
1192 value = str(value)
Adam Israel5e08a0e2018-09-06 19:22:47 -04001193 else:
tierno1afb30a2018-12-21 13:42:43 +00001194 # If there's no data-type, assume the value is a string
1195 value = str(value)
1196 except ValueError:
1197 raise ValueError("parameter {}='{}' cannot be converted to type {}".format(param, value, paramtype))
Adam Israel5e08a0e2018-09-06 19:22:47 -04001198
tierno1afb30a2018-12-21 13:42:43 +00001199 params[param] = value
Adam Israel88a49632018-04-10 13:04:57 -06001200 return params
1201
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001202 def _get_config_from_yang(self, config_primitive, values):
1203 """Transform the yang config primitive to dict."""
1204 config = {}
1205 for primitive in config_primitive.values():
1206 if primitive['name'] == 'config':
1207 for parameter in primitive['parameter'].values():
1208 param = str(parameter['name'])
1209 if parameter['value'] == "<rw_mgmt_ip>":
1210 config[param] = str(values[parameter['value']])
1211 else:
1212 config[param] = str(parameter['value'])
1213
1214 return config
1215
1216 def FormatApplicationName(self, *args):
1217 """
1218 Generate a Juju-compatible Application name
1219
1220 :param args tuple: Positional arguments to be used to construct the
1221 application name.
1222
1223 Limitations::
1224 - Only accepts characters a-z and non-consequitive dashes (-)
1225 - Application name should not exceed 50 characters
1226
1227 Examples::
1228
1229 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
1230 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001231 appname = ""
1232 for c in "-".join(list(args)):
1233 if c.isdigit():
1234 c = chr(97 + int(c))
1235 elif not c.isalpha():
1236 c = "-"
1237 appname += c
Adam Israel6d84dbd2019-03-08 18:33:35 -05001238 return re.sub('-+', '-', appname.lower())
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001239
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001240 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
1241 # """Format the name of the application
1242 #
1243 # Limitations:
1244 # - Only accepts characters a-z and non-consequitive dashes (-)
1245 # - Application name should not exceed 50 characters
1246 # """
1247 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
1248 # new_name = ''
1249 # for c in name:
1250 # if c.isdigit():
1251 # c = chr(97 + int(c))
1252 # elif not c.isalpha():
1253 # c = "-"
1254 # new_name += c
1255 # return re.sub('\-+', '-', new_name.lower())
1256
1257 def format_model_name(self, name):
1258 """Format the name of model.
1259
1260 Model names may only contain lowercase letters, digits and hyphens
1261 """
1262
1263 return name.replace('_', '-').lower()
1264
1265 async def get_application(self, model, application):
1266 """Get the deployed application."""
1267 if not self.authenticated:
1268 await self.login()
1269
1270 app = None
1271 if application and model:
1272 if model.applications:
1273 if application in model.applications:
1274 app = model.applications[application]
1275
1276 return app
1277
Adam Israel85a4b212018-11-29 20:30:24 -05001278 async def get_model(self, model_name):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001279 """Get a model from the Juju Controller.
1280
1281 Note: Model objects returned must call disconnected() before it goes
1282 out of scope."""
1283 if not self.authenticated:
1284 await self.login()
1285
1286 if model_name not in self.models:
Adam Israel85a4b212018-11-29 20:30:24 -05001287 # Get the models in the controller
1288 models = await self.controller.list_models()
1289
1290 if model_name not in models:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001291 try:
1292 self.models[model_name] = await self.controller.add_model(
Adam Israel19c5cfc2019-10-03 12:35:38 -04001293 model_name,
1294 config={'authorized-keys': self.juju_public_key}
1295
Adam Israel6d84dbd2019-03-08 18:33:35 -05001296 )
1297 except JujuError as e:
1298 if "already exists" not in e.message:
1299 raise e
Adam Israel85a4b212018-11-29 20:30:24 -05001300 else:
1301 self.models[model_name] = await self.controller.get_model(
1302 model_name
1303 )
1304
Adam Israelfc511ed2018-09-21 14:20:55 +02001305 self.refcount['model'] += 1
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001306
Adam Israel28a43c02018-04-23 16:04:54 -04001307 # Create an observer for this model
Adam Israel7bf2f4d2019-03-15 15:28:47 -04001308 await self.create_model_monitor(model_name)
1309
1310 return self.models[model_name]
1311
1312 async def create_model_monitor(self, model_name):
1313 """Create a monitor for the model, if none exists."""
1314 if not self.authenticated:
1315 await self.login()
1316
1317 if model_name not in self.monitors:
Adam Israel28a43c02018-04-23 16:04:54 -04001318 self.monitors[model_name] = VCAMonitor(model_name)
1319 self.models[model_name].add_observer(self.monitors[model_name])
1320
Adam Israel7bf2f4d2019-03-15 15:28:47 -04001321 return True
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001322
1323 async def login(self):
1324 """Login to the Juju controller."""
1325
1326 if self.authenticated:
1327 return
1328
1329 self.connecting = True
1330
1331 self.log.debug("JujuApi: Logging into controller")
1332
Adam Israel5e08a0e2018-09-06 19:22:47 -04001333 self.controller = Controller(loop=self.loop)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001334
1335 if self.secret:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001336 self.log.debug(
Adam Israel19c5cfc2019-10-03 12:35:38 -04001337 "Connecting to controller... ws://{} as {}/{}".format(
Adam Israel5e08a0e2018-09-06 19:22:47 -04001338 self.endpoint,
Adam Israel5e08a0e2018-09-06 19:22:47 -04001339 self.user,
1340 self.secret,
1341 )
1342 )
Adam Israel19c5cfc2019-10-03 12:35:38 -04001343 try:
1344 await self.controller.connect(
1345 endpoint=self.endpoint,
1346 username=self.user,
1347 password=self.secret,
1348 cacert=self.ca_cert,
1349 )
1350 self.refcount['controller'] += 1
1351 self.authenticated = True
1352 self.log.debug("JujuApi: Logged into controller")
1353 except Exception as ex:
1354 self.log.debug("Caught exception: {}".format(ex))
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001355 else:
1356 # current_controller no longer exists
1357 # self.log.debug("Connecting to current controller...")
1358 # await self.controller.connect_current()
Adam Israel88a49632018-04-10 13:04:57 -06001359 # await self.controller.connect(
1360 # endpoint=self.endpoint,
1361 # username=self.user,
1362 # cacert=cacert,
1363 # )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001364 self.log.fatal("VCA credentials not configured.")
Adam Israel19c5cfc2019-10-03 12:35:38 -04001365 self.authenticated = False
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001366
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001367
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001368 async def logout(self):
1369 """Logout of the Juju controller."""
1370 if not self.authenticated:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001371 return False
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001372
1373 try:
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001374 for model in self.models:
Adam Israel85a4b212018-11-29 20:30:24 -05001375 await self.disconnect_model(model)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001376
1377 if self.controller:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001378 self.log.debug("Disconnecting controller {}".format(
1379 self.controller
1380 ))
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001381 await self.controller.disconnect()
Adam Israelfc511ed2018-09-21 14:20:55 +02001382 self.refcount['controller'] -= 1
Adam Israel5e08a0e2018-09-06 19:22:47 -04001383 self.controller = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001384
1385 self.authenticated = False
Adam Israelfc511ed2018-09-21 14:20:55 +02001386
1387 self.log.debug(self.refcount)
1388
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001389 except Exception as e:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001390 self.log.fatal(
1391 "Fatal error logging out of Juju Controller: {}".format(e)
1392 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001393 raise e
Adam Israel6d84dbd2019-03-08 18:33:35 -05001394 return True
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001395
Adam Israel85a4b212018-11-29 20:30:24 -05001396 async def disconnect_model(self, model):
1397 self.log.debug("Disconnecting model {}".format(model))
1398 if model in self.models:
Adam Israel0cd1c022019-09-03 18:26:08 -04001399 try:
1400 await self.models[model].disconnect()
1401 self.refcount['model'] -= 1
1402 self.models[model] = None
1403 except Exception as e:
1404 self.log.debug("Caught exception: {}".format(e))
1405
1406 async def provision_machine(self, model_name: str,
1407 hostname: str, username: str,
1408 private_key_path: str) -> int:
1409 """Provision a machine.
1410
1411 This executes the SSH provisioner, which will log in to a machine via
1412 SSH and prepare it for use with the Juju model
1413
1414 :param model_name str: The name of the model
1415 :param hostname str: The IP or hostname of the target VM
1416 :param user str: The username to login to
1417 :param private_key_path str: The path to the private key that's been injected to the VM via cloud-init
1418 :return machine_id int: Returns the id of the machine or None if provisioning fails
1419 """
1420 if not self.authenticated:
1421 await self.login()
1422
1423 machine_id = None
1424
1425 if self.api_proxy:
1426 self.log.debug("Instantiating SSH Provisioner for {}@{} ({})".format(
1427 username,
1428 hostname,
1429 private_key_path
1430 ))
1431 provisioner = SSHProvisioner(
1432 host=hostname,
1433 user=username,
1434 private_key_path=private_key_path,
1435 log=self.log,
1436 )
1437
1438 params = None
1439 try:
1440 params = provisioner.provision_machine()
1441 except Exception as ex:
1442 self.log.debug("caught exception from provision_machine: {}".format(ex))
1443 return None
1444
1445 if params:
1446 params.jobs = ['JobHostUnits']
1447
1448 model = await self.get_model(model_name)
1449
1450 connection = model.connection()
1451
1452 # Submit the request.
1453 self.log.debug("Adding machine to model")
1454 client_facade = client.ClientFacade.from_connection(connection)
1455 results = await client_facade.AddMachines(params=[params])
1456 error = results.machines[0].error
1457 if error:
1458 raise ValueError("Error adding machine: %s" % error.message)
1459
1460 machine_id = results.machines[0].machine
1461
1462 # Need to run this after AddMachines has been called,
1463 # as we need the machine_id
1464 self.log.debug("Installing Juju agent")
1465 await provisioner.install_agent(
1466 connection,
1467 params.nonce,
1468 machine_id,
1469 self.api_proxy,
1470 )
1471 else:
1472 self.log.debug("Missing API Proxy")
1473 return machine_id
Adam Israel85a4b212018-11-29 20:30:24 -05001474
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001475 # async def remove_application(self, name):
1476 # """Remove the application."""
1477 # if not self.authenticated:
1478 # await self.login()
1479 #
1480 # app = await self.get_application(name)
1481 # if app:
1482 # self.log.debug("JujuApi: Destroying application {}".format(
1483 # name,
1484 # ))
1485 #
1486 # await app.destroy()
1487
1488 async def remove_relation(self, a, b):
1489 """
1490 Remove a relation between two application endpoints
1491
1492 :param a An application endpoint
1493 :param b An application endpoint
1494 """
1495 if not self.authenticated:
1496 await self.login()
1497
1498 m = await self.get_model()
1499 try:
1500 m.remove_relation(a, b)
1501 finally:
1502 await m.disconnect()
1503
Adam Israel85a4b212018-11-29 20:30:24 -05001504 async def resolve_error(self, model_name, application=None):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001505 """Resolve units in error state."""
1506 if not self.authenticated:
1507 await self.login()
1508
Adam Israel85a4b212018-11-29 20:30:24 -05001509 model = await self.get_model(model_name)
1510
1511 app = await self.get_application(model, application)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001512 if app:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001513 self.log.debug(
1514 "JujuApi: Resolving errors for application {}".format(
1515 application,
1516 )
1517 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001518
1519 for unit in app.units:
1520 app.resolved(retry=True)
1521
Adam Israel85a4b212018-11-29 20:30:24 -05001522 async def run_action(self, model_name, application, action_name, **params):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001523 """Execute an action and return an Action object."""
1524 if not self.authenticated:
1525 await self.login()
1526 result = {
1527 'status': '',
1528 'action': {
1529 'tag': None,
1530 'results': None,
1531 }
1532 }
Adam Israel85a4b212018-11-29 20:30:24 -05001533
1534 model = await self.get_model(model_name)
1535
1536 app = await self.get_application(model, application)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001537 if app:
1538 # We currently only have one unit per application
1539 # so use the first unit available.
1540 unit = app.units[0]
1541
Adam Israel5e08a0e2018-09-06 19:22:47 -04001542 self.log.debug(
1543 "JujuApi: Running Action {} against Application {}".format(
1544 action_name,
1545 application,
1546 )
1547 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001548
1549 action = await unit.run_action(action_name, **params)
1550
1551 # Wait for the action to complete
1552 await action.wait()
1553
1554 result['status'] = action.status
1555 result['action']['tag'] = action.data['id']
1556 result['action']['results'] = action.results
1557
1558 return result
1559
Adam Israelb5214512018-05-03 10:00:04 -04001560 async def set_config(self, model_name, application, config):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001561 """Apply a configuration to the application."""
1562 if not self.authenticated:
1563 await self.login()
1564
Adam Israelb5214512018-05-03 10:00:04 -04001565 app = await self.get_application(model_name, application)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001566 if app:
1567 self.log.debug("JujuApi: Setting config for Application {}".format(
1568 application,
1569 ))
1570 await app.set_config(config)
1571
1572 # Verify the config is set
1573 newconf = await app.get_config()
1574 for key in config:
1575 if config[key] != newconf[key]['value']:
1576 self.log.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key, config[key], newconf[key]))
1577
Adam Israelb5214512018-05-03 10:00:04 -04001578 # async def set_parameter(self, parameter, value, application=None):
1579 # """Set a config parameter for a service."""
1580 # if not self.authenticated:
1581 # await self.login()
1582 #
1583 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1584 # parameter,
1585 # value,
1586 # application,
1587 # ))
1588 # return await self.apply_config(
1589 # {parameter: value},
1590 # application=application,
1591 # )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001592
Adam Israel5e08a0e2018-09-06 19:22:47 -04001593 async def wait_for_application(self, model_name, application_name,
1594 timeout=300):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001595 """Wait for an application to become active."""
1596 if not self.authenticated:
1597 await self.login()
1598
Adam Israel5e08a0e2018-09-06 19:22:47 -04001599 model = await self.get_model(model_name)
1600
1601 app = await self.get_application(model, application_name)
1602 self.log.debug("Application: {}".format(app))
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001603 if app:
1604 self.log.debug(
1605 "JujuApi: Waiting {} seconds for Application {}".format(
1606 timeout,
Adam Israel5e08a0e2018-09-06 19:22:47 -04001607 application_name,
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001608 )
1609 )
1610
Adam Israel5e08a0e2018-09-06 19:22:47 -04001611 await model.block_until(
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001612 lambda: all(
Adam Israel5e08a0e2018-09-06 19:22:47 -04001613 unit.agent_status == 'idle' and unit.workload_status in
1614 ['active', 'unknown'] for unit in app.units
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001615 ),
1616 timeout=timeout
1617 )