blob: ef7b967fa69f88746ef5cee847dfc3fb422cddba [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
269 self.ca_cert = base64_to_cacert(ca_cert)
270 # self.ca_cert = None
271
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500272
273 # Quiet websocket traffic
274 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
275 logging.getLogger('juju.client.connection').setLevel(logging.WARN)
276 logging.getLogger('model').setLevel(logging.WARN)
277 # logging.getLogger('websockets.protocol').setLevel(logging.DEBUG)
278
279 self.log.debug('JujuApi: instantiated')
280
281 self.server = server
282 self.port = port
283
284 self.secret = secret
285 if user.startswith('user-'):
286 self.user = user
287 else:
288 self.user = 'user-{}'.format(user)
289
290 self.endpoint = '%s:%d' % (server, int(port))
291
292 self.artifacts = artifacts
293
Adam Israel5e08a0e2018-09-06 19:22:47 -0400294 self.loop = loop or asyncio.get_event_loop()
295
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500296 def __del__(self):
297 """Close any open connections."""
298 yield self.logout()
299
Adam Israelb2a07f52019-04-25 17:17:05 -0400300 def _create_juju_public_key(self, public_key):
301 """Recreate the Juju public key on disk.
302
303 Certain libjuju commands expect to be run from the same machine as Juju
304 is bootstrapped to. This method will write the public key to disk in
305 that location: ~/.local/share/juju/ssh/juju_id_rsa.pub
306 """
Adam Israele3a05f82019-04-26 13:12:47 -0400307 # Make sure that we have a public key before writing to disk
Adam Israelb2a07f52019-04-25 17:17:05 -0400308 if public_key is None or len(public_key) == 0:
Adam Israele3a05f82019-04-26 13:12:47 -0400309 if 'OSM_VCA_PUBKEY' in os.environ:
310 public_key = os.getenv('OSM_VCA_PUBKEY', '')
311 if len(public_key == 0):
312 return
313 else:
314 return
315
Adam Israelb2a07f52019-04-25 17:17:05 -0400316 path = "{}/.local/share/juju/ssh".format(
317 os.path.expanduser('~'),
318 )
319 if not os.path.exists(path):
320 os.makedirs(path)
321
322 with open('{}/juju_id_rsa.pub'.format(path), 'w') as f:
323 f.write(public_key)
324
Adam Israel5e08a0e2018-09-06 19:22:47 -0400325 def notify_callback(self, model_name, application_name, status, message,
326 callback=None, *callback_args):
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500327 try:
328 if callback:
Adam Israel5e08a0e2018-09-06 19:22:47 -0400329 callback(
330 model_name,
331 application_name,
332 status, message,
333 *callback_args,
334 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500335 except Exception as e:
336 self.log.error("[0] notify_callback exception {}".format(e))
Adam Israel88a49632018-04-10 13:04:57 -0600337 raise e
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500338 return True
339
340 # Public methods
Adam Israel85a4b212018-11-29 20:30:24 -0500341 async def Relate(self, model_name, vnfd):
Adam Israel136186e2018-09-14 12:01:12 -0400342 """Create a relation between the charm-enabled VDUs in a VNF.
343
344 The Relation mapping has two parts: the id of the vdu owning the endpoint, and the name of the endpoint.
345
346 vdu:
347 ...
Adam Israelbc0daf82019-06-12 17:08:36 -0400348 vca-relationships:
Adam Israel47998e02019-06-03 11:21:27 -0400349 relation:
350 - provides: dataVM:db
351 requires: mgmtVM:app
Adam Israel136186e2018-09-14 12:01:12 -0400352
353 This tells N2VC that the charm referred to by the dataVM vdu offers a relation named 'db', and the mgmtVM vdu has an 'app' endpoint that should be connected to a database.
354
355 :param str ns_name: The name of the network service.
356 :param dict vnfd: The parsed yaml VNF descriptor.
357 """
358
359 # Currently, the call to Relate() is made automatically after the
360 # deployment of each charm; if the relation depends on a charm that
361 # hasn't been deployed yet, the call will fail silently. This will
362 # prevent an API breakage, with the intent of making this an explicitly
363 # required call in a more object-oriented refactor of the N2VC API.
364
365 configs = []
366 vnf_config = vnfd.get("vnf-configuration")
367 if vnf_config:
368 juju = vnf_config['juju']
369 if juju:
370 configs.append(vnf_config)
371
372 for vdu in vnfd['vdu']:
373 vdu_config = vdu.get('vdu-configuration')
374 if vdu_config:
375 juju = vdu_config['juju']
376 if juju:
377 configs.append(vdu_config)
378
379 def _get_application_name(name):
380 """Get the application name that's mapped to a vnf/vdu."""
381 vnf_member_index = 0
382 vnf_name = vnfd['name']
383
384 for vdu in vnfd.get('vdu'):
385 # Compare the named portion of the relation to the vdu's id
386 if vdu['id'] == name:
387 application_name = self.FormatApplicationName(
Adam Israel85a4b212018-11-29 20:30:24 -0500388 model_name,
Adam Israel136186e2018-09-14 12:01:12 -0400389 vnf_name,
390 str(vnf_member_index),
391 )
392 return application_name
393 else:
394 vnf_member_index += 1
395
396 return None
397
398 # Loop through relations
399 for cfg in configs:
400 if 'juju' in cfg:
Adam Israelc92163f2019-05-27 08:39:19 -0400401 juju = cfg['juju']
Adam Israelbc0daf82019-06-12 17:08:36 -0400402 if 'vca-relationships' in juju and 'relation' in juju['vca-relationships']:
403 for rel in juju['vca-relationships']['relation']:
Adam Israel136186e2018-09-14 12:01:12 -0400404 try:
405
406 # get the application name for the provides
407 (name, endpoint) = rel['provides'].split(':')
408 application_name = _get_application_name(name)
409
410 provides = "{}:{}".format(
411 application_name,
412 endpoint
413 )
414
415 # get the application name for thr requires
416 (name, endpoint) = rel['requires'].split(':')
417 application_name = _get_application_name(name)
418
419 requires = "{}:{}".format(
420 application_name,
421 endpoint
422 )
423 self.log.debug("Relation: {} <-> {}".format(
424 provides,
425 requires
426 ))
427 await self.add_relation(
Adam Israel85a4b212018-11-29 20:30:24 -0500428 model_name,
Adam Israel136186e2018-09-14 12:01:12 -0400429 provides,
430 requires,
431 )
432 except Exception as e:
433 self.log.debug("Exception: {}".format(e))
434
435 return
436
Adam Israel5e08a0e2018-09-06 19:22:47 -0400437 async def DeployCharms(self, model_name, application_name, vnfd,
438 charm_path, params={}, machine_spec={},
439 callback=None, *callback_args):
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500440 """Deploy one or more charms associated with a VNF.
441
442 Deploy the charm(s) referenced in a VNF Descriptor.
443
Adam Israel85a4b212018-11-29 20:30:24 -0500444 :param str model_name: The name or unique id of the network service.
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500445 :param str application_name: The name of the application
446 :param dict vnfd: The name of the application
447 :param str charm_path: The path to the Juju charm
448 :param dict params: A dictionary of runtime parameters
449 Examples::
450 {
Adam Israel88a49632018-04-10 13:04:57 -0600451 'rw_mgmt_ip': '1.2.3.4',
452 # Pass the initial-config-primitives section of the vnf or vdu
453 'initial-config-primitives': {...}
tierno1afb30a2018-12-21 13:42:43 +0000454 'user_values': dictionary with the day-1 parameters provided at instantiation time. It will replace values
455 inside < >. rw_mgmt_ip will be included here also
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500456 }
Adam Israel5e08a0e2018-09-06 19:22:47 -0400457 :param dict machine_spec: A dictionary describing the machine to
458 install to
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500459 Examples::
460 {
461 'hostname': '1.2.3.4',
462 'username': 'ubuntu',
463 }
464 :param obj callback: A callback function to receive status changes.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400465 :param tuple callback_args: A list of arguments to be passed to the
466 callback
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500467 """
468
469 ########################################################
470 # Verify the path to the charm exists and is readable. #
471 ########################################################
472 if not os.path.exists(charm_path):
473 self.log.debug("Charm path doesn't exist: {}".format(charm_path))
Adam Israel5e08a0e2018-09-06 19:22:47 -0400474 self.notify_callback(
475 model_name,
476 application_name,
477 "failed",
478 callback,
479 *callback_args,
480 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500481 raise JujuCharmNotFound("No artifacts configured.")
482
483 ################################
484 # Login to the Juju controller #
485 ################################
486 if not self.authenticated:
487 self.log.debug("Authenticating with Juju")
488 await self.login()
489
490 ##########################################
491 # Get the model for this network service #
492 ##########################################
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500493 model = await self.get_model(model_name)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500494
495 ########################################
496 # Verify the application doesn't exist #
497 ########################################
498 app = await self.get_application(model, application_name)
499 if app:
Adam Israel42d88e62018-07-16 14:18:41 -0400500 raise JujuApplicationExists("Can't deploy application \"{}\" to model \"{}\" because it already exists.".format(application_name, model_name))
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500501
Adam Israel28a43c02018-04-23 16:04:54 -0400502 ################################################################
503 # Register this application with the model-level event monitor #
504 ################################################################
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500505 if callback:
Adam Israel04eee1f2019-04-29 14:59:45 -0400506 self.log.debug("JujuApi: Registering callback for {}".format(
Adam Israel28a43c02018-04-23 16:04:54 -0400507 application_name,
Adam Israel04eee1f2019-04-29 14:59:45 -0400508 ))
509 await self.Subscribe(model_name, application_name, callback, *callback_args)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500510
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500511 #######################################
512 # Get the initial charm configuration #
513 #######################################
514
515 rw_mgmt_ip = None
516 if 'rw_mgmt_ip' in params:
517 rw_mgmt_ip = params['rw_mgmt_ip']
518
Adam Israel5afe0542018-08-08 12:54:55 -0400519 if 'initial-config-primitive' not in params:
520 params['initial-config-primitive'] = {}
521
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500522 initial_config = self._get_config_from_dict(
Adam Israel88a49632018-04-10 13:04:57 -0600523 params['initial-config-primitive'],
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500524 {'<rw_mgmt_ip>': rw_mgmt_ip}
525 )
526
Adam Israel0cd1c022019-09-03 18:26:08 -0400527 ########################################################
528 # Check for specific machine placement (native charms) #
529 ########################################################
530 to = ""
531 series = "xenial"
532
533 if machine_spec.keys():
534 if all(k in machine_spec for k in ['hostname', 'username']):
535
536 # Allow series to be derived from the native charm
537 series = None
538
539 self.log.debug("Provisioning manual machine {}@{}".format(
540 machine_spec['username'],
541 machine_spec['hostname'],
542 ))
543
544 """Native Charm support
545
546 Taking a bare VM (assumed to be an Ubuntu cloud image),
547 the provisioning process will:
548 - Create an ubuntu user w/sudo access
549 - Detect hardware
550 - Detect architecture
551 - Download and install Juju agent from controller
552 - Enable Juju agent
553 - Add an iptables rule to route traffic to the API proxy
554 """
555
556 to = await self.provision_machine(
557 model_name=model_name,
558 username=machine_spec['username'],
559 hostname=machine_spec['hostname'],
560 private_key_path=self.GetPrivateKeyPath(),
561 )
562 self.log.debug("Provisioned machine id {}".format(to))
563
564 # TODO: If to is none, raise an exception
565
566 # The native charm won't have the sshproxy layer, typically, but LCM uses the config primitive
567 # to interpret what the values are. That's a gap to fill.
568
569 """
570 The ssh-* config parameters are unique to the sshproxy layer,
571 which most native charms will not be aware of.
572
573 Setting invalid config parameters will cause the deployment to
574 fail.
575
576 For the moment, we will strip the ssh-* parameters from native
577 charms, until the feature gap is addressed in the information
578 model.
579 """
580
581 # Native charms don't include the ssh-* config values, so strip them
582 # from the initial_config, otherwise the deploy will raise an error.
583 # self.log.debug("Removing ssh-* from initial-config")
584 for k in ['ssh-hostname', 'ssh-username', 'ssh-password']:
585 if k in initial_config:
586 self.log.debug("Removing parameter {}".format(k))
587 del initial_config[k]
588
589 self.log.debug("JujuApi: Deploying charm ({}/{}) from {} to {}".format(
Adam Israel85a4b212018-11-29 20:30:24 -0500590 model_name,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500591 application_name,
592 charm_path,
Adam Israel0cd1c022019-09-03 18:26:08 -0400593 to,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500594 ))
595
596 ########################################################
597 # Deploy the charm and apply the initial configuration #
598 ########################################################
599 app = await model.deploy(
Adam Israel88a49632018-04-10 13:04:57 -0600600 # We expect charm_path to be either the path to the charm on disk
601 # or in the format of cs:series/name
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500602 charm_path,
Adam Israel88a49632018-04-10 13:04:57 -0600603 # This is the formatted, unique name for this charm
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500604 application_name=application_name,
Adam Israel88a49632018-04-10 13:04:57 -0600605 # Proxy charms should use the current LTS. This will need to be
606 # changed for native charms.
Adam Israel0cd1c022019-09-03 18:26:08 -0400607 series=series,
Adam Israel88a49632018-04-10 13:04:57 -0600608 # Apply the initial 'config' primitive during deployment
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500609 config=initial_config,
Adam Israelfa329072018-09-14 11:26:13 -0400610 # Where to deploy the charm to.
611 to=to,
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500612 )
Adam Israel0cd1c022019-09-03 18:26:08 -0400613
Adam Israel38bf1642019-05-31 09:59:52 -0400614 #############################
615 # Map the vdu id<->app name #
616 #############################
617 try:
618 await self.Relate(model_name, vnfd)
619 except KeyError as ex:
620 # We don't currently support relations between NS and VNF/VDU charms
621 self.log.warn("[N2VC] Relations not supported: {}".format(ex))
622 except Exception as ex:
623 # This may happen if not all of the charms needed by the relation
624 # are ready. We can safely ignore this, because Relate will be
625 # retried when the endpoint of the relation is deployed.
626 self.log.warn("[N2VC] Relations not ready")
Adam Israel136186e2018-09-14 12:01:12 -0400627
Adam Israel88a49632018-04-10 13:04:57 -0600628 # #######################################
629 # # Execute initial config primitive(s) #
630 # #######################################
Adam Israelcf253202018-10-31 16:29:09 -0700631 uuids = await self.ExecuteInitialPrimitives(
Adam Israel5e08a0e2018-09-06 19:22:47 -0400632 model_name,
633 application_name,
634 params,
635 )
Adam Israelcf253202018-10-31 16:29:09 -0700636 return uuids
Adam Israel5e08a0e2018-09-06 19:22:47 -0400637
638 # primitives = {}
639 #
640 # # Build a sequential list of the primitives to execute
641 # for primitive in params['initial-config-primitive']:
642 # try:
643 # if primitive['name'] == 'config':
644 # # This is applied when the Application is deployed
645 # pass
646 # else:
647 # seq = primitive['seq']
648 #
649 # params = {}
650 # if 'parameter' in primitive:
651 # params = primitive['parameter']
652 #
653 # primitives[seq] = {
654 # 'name': primitive['name'],
655 # 'parameters': self._map_primitive_parameters(
656 # params,
657 # {'<rw_mgmt_ip>': rw_mgmt_ip}
658 # ),
659 # }
660 #
661 # for primitive in sorted(primitives):
662 # await self.ExecutePrimitive(
663 # model_name,
664 # application_name,
665 # primitives[primitive]['name'],
666 # callback,
667 # callback_args,
668 # **primitives[primitive]['parameters'],
669 # )
670 # except N2VCPrimitiveExecutionFailed as e:
671 # self.log.debug(
672 # "[N2VC] Exception executing primitive: {}".format(e)
673 # )
674 # raise
675
676 async def GetPrimitiveStatus(self, model_name, uuid):
677 """Get the status of an executed Primitive.
678
679 The status of an executed Primitive will be one of three values:
680 - completed
681 - failed
682 - running
683 """
684 status = None
685 try:
686 if not self.authenticated:
687 await self.login()
688
Adam Israel5e08a0e2018-09-06 19:22:47 -0400689 model = await self.get_model(model_name)
690
691 results = await model.get_action_status(uuid)
692
693 if uuid in results:
694 status = results[uuid]
695
696 except Exception as e:
697 self.log.debug(
698 "Caught exception while getting primitive status: {}".format(e)
699 )
700 raise N2VCPrimitiveExecutionFailed(e)
701
702 return status
703
704 async def GetPrimitiveOutput(self, model_name, uuid):
705 """Get the output of an executed Primitive.
706
707 Note: this only returns output for a successfully executed primitive.
708 """
709 results = None
710 try:
711 if not self.authenticated:
712 await self.login()
713
Adam Israel5e08a0e2018-09-06 19:22:47 -0400714 model = await self.get_model(model_name)
715 results = await model.get_action_output(uuid, 60)
716 except Exception as e:
717 self.log.debug(
718 "Caught exception while getting primitive status: {}".format(e)
719 )
720 raise N2VCPrimitiveExecutionFailed(e)
721
722 return results
723
Adam Israelfa329072018-09-14 11:26:13 -0400724 # async def ProvisionMachine(self, model_name, hostname, username):
725 # """Provision machine for usage with Juju.
726 #
727 # Provisions a previously instantiated machine for use with Juju.
728 # """
729 # try:
730 # if not self.authenticated:
731 # await self.login()
732 #
733 # # FIXME: This is hard-coded until model-per-ns is added
734 # model_name = 'default'
735 #
736 # model = await self.get_model(model_name)
737 # model.add_machine(spec={})
738 #
739 # machine = await model.add_machine(spec='ssh:{}@{}:{}'.format(
740 # "ubuntu",
741 # host['address'],
742 # private_key_path,
743 # ))
744 # return machine.id
745 #
746 # except Exception as e:
747 # self.log.debug(
748 # "Caught exception while getting primitive status: {}".format(e)
749 # )
750 # raise N2VCPrimitiveExecutionFailed(e)
751
752 def GetPrivateKeyPath(self):
753 homedir = os.environ['HOME']
754 sshdir = "{}/.ssh".format(homedir)
755 private_key_path = "{}/id_n2vc_rsa".format(sshdir)
756 return private_key_path
757
758 async def GetPublicKey(self):
759 """Get the N2VC SSH public key.abs
760
761 Returns the SSH public key, to be injected into virtual machines to
762 be managed by the VCA.
763
764 The first time this is run, a ssh keypair will be created. The public
765 key is injected into a VM so that we can provision the machine with
766 Juju, after which Juju will communicate with the VM directly via the
767 juju agent.
768 """
769 public_key = ""
770
771 # Find the path to where we expect our key to live.
772 homedir = os.environ['HOME']
773 sshdir = "{}/.ssh".format(homedir)
774 if not os.path.exists(sshdir):
775 os.mkdir(sshdir)
776
777 private_key_path = "{}/id_n2vc_rsa".format(sshdir)
778 public_key_path = "{}.pub".format(private_key_path)
779
780 # If we don't have a key generated, generate it.
781 if not os.path.exists(private_key_path):
782 cmd = "ssh-keygen -t {} -b {} -N '' -f {}".format(
783 "rsa",
784 "4096",
785 private_key_path
786 )
787 subprocess.check_output(shlex.split(cmd))
788
789 # Read the public key
790 with open(public_key_path, "r") as f:
791 public_key = f.readline()
792
793 return public_key
794
Adam Israel5e08a0e2018-09-06 19:22:47 -0400795 async def ExecuteInitialPrimitives(self, model_name, application_name,
796 params, callback=None, *callback_args):
797 """Execute multiple primitives.
798
799 Execute multiple primitives as declared in initial-config-primitive.
800 This is useful in cases where the primitives initially failed -- for
801 example, if the charm is a proxy but the proxy hasn't been configured
802 yet.
803 """
804 uuids = []
Adam Israel88a49632018-04-10 13:04:57 -0600805 primitives = {}
806
807 # Build a sequential list of the primitives to execute
808 for primitive in params['initial-config-primitive']:
809 try:
810 if primitive['name'] == 'config':
Adam Israel88a49632018-04-10 13:04:57 -0600811 pass
812 else:
Adam Israel88a49632018-04-10 13:04:57 -0600813 seq = primitive['seq']
814
tierno1afb30a2018-12-21 13:42:43 +0000815 params_ = {}
Adam Israel42d88e62018-07-16 14:18:41 -0400816 if 'parameter' in primitive:
tierno1afb30a2018-12-21 13:42:43 +0000817 params_ = primitive['parameter']
818
819 user_values = params.get("user_values", {})
820 if 'rw_mgmt_ip' not in user_values:
821 user_values['rw_mgmt_ip'] = None
822 # just for backward compatibility, because it will be provided always by modern version of LCM
Adam Israel42d88e62018-07-16 14:18:41 -0400823
Adam Israel88a49632018-04-10 13:04:57 -0600824 primitives[seq] = {
825 'name': primitive['name'],
826 'parameters': self._map_primitive_parameters(
tierno1afb30a2018-12-21 13:42:43 +0000827 params_,
828 user_values
Adam Israel88a49632018-04-10 13:04:57 -0600829 ),
830 }
831
832 for primitive in sorted(primitives):
Adam Israel32a15192019-06-24 11:44:47 -0400833 try:
834 # self.log.debug("Queuing action {}".format(primitives[primitive]['name']))
835 uuids.append(
836 await self.ExecutePrimitive(
837 model_name,
838 application_name,
839 primitives[primitive]['name'],
840 callback,
841 callback_args,
842 **primitives[primitive]['parameters'],
843 )
Adam Israel5e08a0e2018-09-06 19:22:47 -0400844 )
Adam Israel32a15192019-06-24 11:44:47 -0400845 except PrimitiveDoesNotExist as e:
846 self.log.debug("Ignoring exception PrimitiveDoesNotExist: {}".format(e))
847 pass
848 except Exception as e:
849 self.log.debug("XXXXXXXXXXXXXXXXXXXXXXXXX Unexpected exception: {}".format(e))
850 raise e
851
Adam Israel88a49632018-04-10 13:04:57 -0600852 except N2VCPrimitiveExecutionFailed as e:
Adam Israel7d871fb2018-07-17 12:17:06 -0400853 self.log.debug(
Adam Israel88a49632018-04-10 13:04:57 -0600854 "[N2VC] Exception executing primitive: {}".format(e)
855 )
856 raise
Adam Israel5e08a0e2018-09-06 19:22:47 -0400857 return uuids
Adam Israel88a49632018-04-10 13:04:57 -0600858
Adam Israel5e08a0e2018-09-06 19:22:47 -0400859 async def ExecutePrimitive(self, model_name, application_name, primitive,
860 callback, *callback_args, **params):
Adam Israelc9df96f2018-05-03 14:49:56 -0400861 """Execute a primitive of a charm for Day 1 or Day 2 configuration.
Adam Israel6817f612018-04-13 08:41:43 -0600862
Adam Israelc9df96f2018-05-03 14:49:56 -0400863 Execute a primitive defined in the VNF descriptor.
864
Adam Israel85a4b212018-11-29 20:30:24 -0500865 :param str model_name: The name or unique id of the network service.
Adam Israelc9df96f2018-05-03 14:49:56 -0400866 :param str application_name: The name of the application
867 :param str primitive: The name of the primitive to execute.
868 :param obj callback: A callback function to receive status changes.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400869 :param tuple callback_args: A list of arguments to be passed to the
870 callback function.
871 :param dict params: A dictionary of key=value pairs representing the
872 primitive's parameters
Adam Israelc9df96f2018-05-03 14:49:56 -0400873 Examples::
874 {
875 'rw_mgmt_ip': '1.2.3.4',
876 # Pass the initial-config-primitives section of the vnf or vdu
877 'initial-config-primitives': {...}
878 }
Adam Israel6817f612018-04-13 08:41:43 -0600879 """
tierno1afb30a2018-12-21 13:42:43 +0000880 self.log.debug("Executing primitive={} params={}".format(primitive, params))
Adam Israel6817f612018-04-13 08:41:43 -0600881 uuid = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500882 try:
883 if not self.authenticated:
884 await self.login()
885
Adam Israel5e08a0e2018-09-06 19:22:47 -0400886 model = await self.get_model(model_name)
Adam Israelb5214512018-05-03 10:00:04 -0400887
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500888 if primitive == 'config':
889 # config is special, and expecting params to be a dictionary
Adam Israelb0943662018-08-02 15:32:00 -0400890 await self.set_config(
891 model,
892 application_name,
893 params['params'],
894 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500895 else:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500896 app = await self.get_application(model, application_name)
897 if app:
Adam Israel32a15192019-06-24 11:44:47 -0400898 # Does this primitive exist?
899 actions = await app.get_actions()
900
901 if primitive not in actions.keys():
902 raise PrimitiveDoesNotExist("Primitive {} does not exist".format(primitive))
903
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500904 # Run against the first (and probably only) unit in the app
905 unit = app.units[0]
906 if unit:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500907 action = await unit.run_action(primitive, **params)
Adam Israel6817f612018-04-13 08:41:43 -0600908 uuid = action.id
Adam Israel32a15192019-06-24 11:44:47 -0400909 except PrimitiveDoesNotExist as e:
910 # Catch and raise this exception if it's thrown from the inner block
911 raise e
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500912 except Exception as e:
Adam Israel32a15192019-06-24 11:44:47 -0400913 # An unexpected exception was caught
Adam Israelb0943662018-08-02 15:32:00 -0400914 self.log.debug(
915 "Caught exception while executing primitive: {}".format(e)
916 )
Adam Israel7d871fb2018-07-17 12:17:06 -0400917 raise N2VCPrimitiveExecutionFailed(e)
Adam Israel6817f612018-04-13 08:41:43 -0600918 return uuid
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500919
Adam Israel5e08a0e2018-09-06 19:22:47 -0400920 async def RemoveCharms(self, model_name, application_name, callback=None,
921 *callback_args):
Adam Israelc9df96f2018-05-03 14:49:56 -0400922 """Remove a charm from the VCA.
923
924 Remove a charm referenced in a VNF Descriptor.
925
926 :param str model_name: The name of the network service.
927 :param str application_name: The name of the application
928 :param obj callback: A callback function to receive status changes.
Adam Israel5e08a0e2018-09-06 19:22:47 -0400929 :param tuple callback_args: A list of arguments to be passed to the
930 callback function.
Adam Israelc9df96f2018-05-03 14:49:56 -0400931 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500932 try:
933 if not self.authenticated:
934 await self.login()
935
936 model = await self.get_model(model_name)
937 app = await self.get_application(model, application_name)
938 if app:
Adam Israel28a43c02018-04-23 16:04:54 -0400939 # Remove this application from event monitoring
Adam Israel04eee1f2019-04-29 14:59:45 -0400940 await self.Unsubscribe(model_name, application_name)
Adam Israel28a43c02018-04-23 16:04:54 -0400941
942 # self.notify_callback(model_name, application_name, "removing", callback, *callback_args)
Adam Israel5e08a0e2018-09-06 19:22:47 -0400943 self.log.debug(
944 "Removing the application {}".format(application_name)
945 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500946 await app.remove()
Adam Israel28a43c02018-04-23 16:04:54 -0400947
Adam Israel0cd1c022019-09-03 18:26:08 -0400948 # await self.disconnect_model(self.monitors[model_name])
Adam Israel85a4b212018-11-29 20:30:24 -0500949
Adam Israel5e08a0e2018-09-06 19:22:47 -0400950 self.notify_callback(
951 model_name,
952 application_name,
953 "removed",
Adam Israelc4f393e2019-03-19 16:33:30 -0400954 "Removing charm {}".format(application_name),
Adam Israel5e08a0e2018-09-06 19:22:47 -0400955 callback,
956 *callback_args,
957 )
Adam Israel28a43c02018-04-23 16:04:54 -0400958
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500959 except Exception as e:
960 print("Caught exception: {}".format(e))
Adam Israel88a49632018-04-10 13:04:57 -0600961 self.log.debug(e)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500962 raise e
963
Adam Israel6d84dbd2019-03-08 18:33:35 -0500964 async def CreateNetworkService(self, ns_uuid):
965 """Create a new Juju model for the Network Service.
966
967 Creates a new Model in the Juju Controller.
968
969 :param str ns_uuid: A unique id representing an instaance of a
970 Network Service.
971
972 :returns: True if the model was created. Raises JujuError on failure.
973 """
974 if not self.authenticated:
975 await self.login()
976
977 models = await self.controller.list_models()
978 if ns_uuid not in models:
Adam Israel19c5cfc2019-10-03 12:35:38 -0400979 # Get the new model
980 await self.get_model(ns_uuid)
Adam Israel7bf2f4d2019-03-15 15:28:47 -0400981
Adam Israel6d84dbd2019-03-08 18:33:35 -0500982 return True
983
984 async def DestroyNetworkService(self, ns_uuid):
985 """Destroy a Network Service.
986
987 Destroy the Network Service and any deployed charms.
988
989 :param ns_uuid The unique id of the Network Service
990
991 :returns: True if the model was created. Raises JujuError on failure.
992 """
993
994 # Do not delete the default model. The default model was used by all
995 # Network Services, prior to the implementation of a model per NS.
Adam Israelc4f393e2019-03-19 16:33:30 -0400996 if ns_uuid.lower() == "default":
Adam Israel6d84dbd2019-03-08 18:33:35 -0500997 return False
998
999 if not self.authenticated:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001000 await self.login()
1001
Adam Israel0cd1c022019-09-03 18:26:08 -04001002 models = await self.controller.list_models()
1003 if ns_uuid in models:
1004 model = await self.controller.get_model(ns_uuid)
1005
1006 for application in model.applications:
1007 app = model.applications[application]
1008
1009 await self.RemoveCharms(ns_uuid, application)
1010
1011 self.log.debug("Unsubscribing Watcher for {}".format(application))
1012 await self.Unsubscribe(ns_uuid, application)
1013
1014 self.log.debug("Waiting for application to terminate")
1015 timeout = 30
1016 try:
1017 await model.block_until(
1018 lambda: all(
1019 unit.workload_status in ['terminated'] for unit in app.units
1020 ),
1021 timeout=timeout
1022 )
1023 except Exception as e:
1024 self.log.debug("Timed out waiting for {} to terminate.".format(application))
1025
1026 for machine in model.machines:
1027 try:
1028 self.log.debug("Destroying machine {}".format(machine))
1029 await model.machines[machine].destroy(force=True)
1030 except JujuAPIError as e:
1031 if 'does not exist' in str(e):
1032 # Our cached model may be stale, because the machine
1033 # has already been removed. It's safe to continue.
1034 continue
1035 else:
1036 self.log.debug("Caught exception: {}".format(e))
1037 raise e
1038
Adam Israel6d84dbd2019-03-08 18:33:35 -05001039 # Disconnect from the Model
1040 if ns_uuid in self.models:
Adam Israel0cd1c022019-09-03 18:26:08 -04001041 self.log.debug("Disconnecting model {}".format(ns_uuid))
1042 # await self.disconnect_model(self.models[ns_uuid])
1043 await self.disconnect_model(ns_uuid)
Adam Israel6d84dbd2019-03-08 18:33:35 -05001044
1045 try:
Adam Israel0cd1c022019-09-03 18:26:08 -04001046 self.log.debug("Destroying model {}".format(ns_uuid))
Adam Israel6d84dbd2019-03-08 18:33:35 -05001047 await self.controller.destroy_models(ns_uuid)
Adam Israelc4f393e2019-03-19 16:33:30 -04001048 except JujuError:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001049 raise NetworkServiceDoesNotExist(
1050 "The Network Service '{}' does not exist".format(ns_uuid)
1051 )
1052
1053 return True
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001054
Adam Israelb5214512018-05-03 10:00:04 -04001055 async def GetMetrics(self, model_name, application_name):
1056 """Get the metrics collected by the VCA.
1057
Adam Israel85a4b212018-11-29 20:30:24 -05001058 :param model_name The name or unique id of the network service
Adam Israelb5214512018-05-03 10:00:04 -04001059 :param application_name The name of the application
1060 """
1061 metrics = {}
1062 model = await self.get_model(model_name)
1063 app = await self.get_application(model, application_name)
1064 if app:
1065 metrics = await app.get_metrics()
1066
1067 return metrics
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001068
Adam Israelfa329072018-09-14 11:26:13 -04001069 async def HasApplication(self, model_name, application_name):
1070 model = await self.get_model(model_name)
1071 app = await self.get_application(model, application_name)
1072 if app:
1073 return True
1074 return False
1075
Adam Israel04eee1f2019-04-29 14:59:45 -04001076 async def Subscribe(self, ns_name, application_name, callback, *callback_args):
1077 """Subscribe to callbacks for an application.
1078
1079 :param ns_name str: The name of the Network Service
1080 :param application_name str: The name of the application
1081 :param callback obj: The callback method
1082 :param callback_args list: The list of arguments to append to calls to
1083 the callback method
1084 """
1085 self.monitors[ns_name].AddApplication(
1086 application_name,
1087 callback,
1088 *callback_args
1089 )
1090
1091 async def Unsubscribe(self, ns_name, application_name):
1092 """Unsubscribe to callbacks for an application.
1093
1094 Unsubscribes the caller from notifications from a deployed application.
1095
1096 :param ns_name str: The name of the Network Service
1097 :param application_name str: The name of the application
1098 """
1099 self.monitors[ns_name].RemoveApplication(
1100 application_name,
1101 )
1102
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001103 # Non-public methods
Adam Israel136186e2018-09-14 12:01:12 -04001104 async def add_relation(self, model_name, relation1, relation2):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001105 """
1106 Add a relation between two application endpoints.
1107
Adam Israel85a4b212018-11-29 20:30:24 -05001108 :param str model_name: The name or unique id of the network service
1109 :param str relation1: '<application>[:<relation_name>]'
1110 :param str relation2: '<application>[:<relation_name>]'
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001111 """
Adam Israel136186e2018-09-14 12:01:12 -04001112
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001113 if not self.authenticated:
1114 await self.login()
1115
Adam Israel136186e2018-09-14 12:01:12 -04001116 m = await self.get_model(model_name)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001117 try:
Adam Israel136186e2018-09-14 12:01:12 -04001118 await m.add_relation(relation1, relation2)
1119 except JujuAPIError as e:
1120 # If one of the applications in the relationship doesn't exist,
1121 # or the relation has already been added, let the operation fail
1122 # silently.
1123 if 'not found' in e.message:
1124 return
1125 if 'already exists' in e.message:
1126 return
1127
1128 raise e
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001129
Adam Israelb5214512018-05-03 10:00:04 -04001130 # async def apply_config(self, config, application):
1131 # """Apply a configuration to the application."""
1132 # print("JujuApi: Applying configuration to {}.".format(
1133 # application
1134 # ))
1135 # return await self.set_config(application=application, config=config)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001136
1137 def _get_config_from_dict(self, config_primitive, values):
Adam Israel88a49632018-04-10 13:04:57 -06001138 """Transform the yang config primitive to dict.
1139
1140 Expected result:
1141
1142 config = {
1143 'config':
1144 }
1145 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001146 config = {}
1147 for primitive in config_primitive:
1148 if primitive['name'] == 'config':
Adam Israel88a49632018-04-10 13:04:57 -06001149 # config = self._map_primitive_parameters()
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001150 for parameter in primitive['parameter']:
1151 param = str(parameter['name'])
1152 if parameter['value'] == "<rw_mgmt_ip>":
1153 config[param] = str(values[parameter['value']])
1154 else:
1155 config[param] = str(parameter['value'])
1156
1157 return config
1158
tierno1afb30a2018-12-21 13:42:43 +00001159 def _map_primitive_parameters(self, parameters, user_values):
Adam Israel88a49632018-04-10 13:04:57 -06001160 params = {}
1161 for parameter in parameters:
1162 param = str(parameter['name'])
tierno1afb30a2018-12-21 13:42:43 +00001163 value = parameter.get('value')
1164
1165 # map parameters inside a < >; e.g. <rw_mgmt_ip>. with the provided user_values.
1166 # Must exist at user_values except if there is a default value
1167 if isinstance(value, str) and value.startswith("<") and value.endswith(">"):
1168 if parameter['value'][1:-1] in user_values:
1169 value = user_values[parameter['value'][1:-1]]
1170 elif 'default-value' in parameter:
1171 value = parameter['default-value']
1172 else:
1173 raise KeyError("parameter {}='{}' not supplied ".format(param, value))
Adam Israel5e08a0e2018-09-06 19:22:47 -04001174
Adam Israelbf793522018-11-20 13:54:13 -05001175 # If there's no value, use the default-value (if set)
tierno1afb30a2018-12-21 13:42:43 +00001176 if value is None and 'default-value' in parameter:
Adam Israelbf793522018-11-20 13:54:13 -05001177 value = parameter['default-value']
1178
Adam Israel5e08a0e2018-09-06 19:22:47 -04001179 # Typecast parameter value, if present
tierno1afb30a2018-12-21 13:42:43 +00001180 paramtype = "string"
1181 try:
1182 if 'data-type' in parameter:
1183 paramtype = str(parameter['data-type']).lower()
Adam Israel5e08a0e2018-09-06 19:22:47 -04001184
tierno1afb30a2018-12-21 13:42:43 +00001185 if paramtype == "integer":
1186 value = int(value)
1187 elif paramtype == "boolean":
1188 value = bool(value)
1189 else:
1190 value = str(value)
Adam Israel5e08a0e2018-09-06 19:22:47 -04001191 else:
tierno1afb30a2018-12-21 13:42:43 +00001192 # If there's no data-type, assume the value is a string
1193 value = str(value)
1194 except ValueError:
1195 raise ValueError("parameter {}='{}' cannot be converted to type {}".format(param, value, paramtype))
Adam Israel5e08a0e2018-09-06 19:22:47 -04001196
tierno1afb30a2018-12-21 13:42:43 +00001197 params[param] = value
Adam Israel88a49632018-04-10 13:04:57 -06001198 return params
1199
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001200 def _get_config_from_yang(self, config_primitive, values):
1201 """Transform the yang config primitive to dict."""
1202 config = {}
1203 for primitive in config_primitive.values():
1204 if primitive['name'] == 'config':
1205 for parameter in primitive['parameter'].values():
1206 param = str(parameter['name'])
1207 if parameter['value'] == "<rw_mgmt_ip>":
1208 config[param] = str(values[parameter['value']])
1209 else:
1210 config[param] = str(parameter['value'])
1211
1212 return config
1213
1214 def FormatApplicationName(self, *args):
1215 """
1216 Generate a Juju-compatible Application name
1217
1218 :param args tuple: Positional arguments to be used to construct the
1219 application name.
1220
1221 Limitations::
1222 - Only accepts characters a-z and non-consequitive dashes (-)
1223 - Application name should not exceed 50 characters
1224
1225 Examples::
1226
1227 FormatApplicationName("ping_pong_ns", "ping_vnf", "a")
1228 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001229 appname = ""
1230 for c in "-".join(list(args)):
1231 if c.isdigit():
1232 c = chr(97 + int(c))
1233 elif not c.isalpha():
1234 c = "-"
1235 appname += c
Adam Israel6d84dbd2019-03-08 18:33:35 -05001236 return re.sub('-+', '-', appname.lower())
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001237
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001238 # def format_application_name(self, nsd_name, vnfr_name, member_vnf_index=0):
1239 # """Format the name of the application
1240 #
1241 # Limitations:
1242 # - Only accepts characters a-z and non-consequitive dashes (-)
1243 # - Application name should not exceed 50 characters
1244 # """
1245 # name = "{}-{}-{}".format(nsd_name, vnfr_name, member_vnf_index)
1246 # new_name = ''
1247 # for c in name:
1248 # if c.isdigit():
1249 # c = chr(97 + int(c))
1250 # elif not c.isalpha():
1251 # c = "-"
1252 # new_name += c
1253 # return re.sub('\-+', '-', new_name.lower())
1254
1255 def format_model_name(self, name):
1256 """Format the name of model.
1257
1258 Model names may only contain lowercase letters, digits and hyphens
1259 """
1260
1261 return name.replace('_', '-').lower()
1262
1263 async def get_application(self, model, application):
1264 """Get the deployed application."""
1265 if not self.authenticated:
1266 await self.login()
1267
1268 app = None
1269 if application and model:
1270 if model.applications:
1271 if application in model.applications:
1272 app = model.applications[application]
1273
1274 return app
1275
Adam Israel85a4b212018-11-29 20:30:24 -05001276 async def get_model(self, model_name):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001277 """Get a model from the Juju Controller.
1278
1279 Note: Model objects returned must call disconnected() before it goes
1280 out of scope."""
1281 if not self.authenticated:
1282 await self.login()
1283
1284 if model_name not in self.models:
Adam Israel85a4b212018-11-29 20:30:24 -05001285 # Get the models in the controller
1286 models = await self.controller.list_models()
1287
1288 if model_name not in models:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001289 try:
1290 self.models[model_name] = await self.controller.add_model(
Adam Israel19c5cfc2019-10-03 12:35:38 -04001291 model_name,
1292 config={'authorized-keys': self.juju_public_key}
1293
Adam Israel6d84dbd2019-03-08 18:33:35 -05001294 )
1295 except JujuError as e:
1296 if "already exists" not in e.message:
1297 raise e
Adam Israel85a4b212018-11-29 20:30:24 -05001298 else:
1299 self.models[model_name] = await self.controller.get_model(
1300 model_name
1301 )
1302
Adam Israelfc511ed2018-09-21 14:20:55 +02001303 self.refcount['model'] += 1
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001304
Adam Israel28a43c02018-04-23 16:04:54 -04001305 # Create an observer for this model
Adam Israel7bf2f4d2019-03-15 15:28:47 -04001306 await self.create_model_monitor(model_name)
1307
1308 return self.models[model_name]
1309
1310 async def create_model_monitor(self, model_name):
1311 """Create a monitor for the model, if none exists."""
1312 if not self.authenticated:
1313 await self.login()
1314
1315 if model_name not in self.monitors:
Adam Israel28a43c02018-04-23 16:04:54 -04001316 self.monitors[model_name] = VCAMonitor(model_name)
1317 self.models[model_name].add_observer(self.monitors[model_name])
1318
Adam Israel7bf2f4d2019-03-15 15:28:47 -04001319 return True
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001320
1321 async def login(self):
1322 """Login to the Juju controller."""
1323
1324 if self.authenticated:
1325 return
1326
1327 self.connecting = True
1328
1329 self.log.debug("JujuApi: Logging into controller")
1330
Adam Israel5e08a0e2018-09-06 19:22:47 -04001331 self.controller = Controller(loop=self.loop)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001332
1333 if self.secret:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001334 self.log.debug(
Adam Israel19c5cfc2019-10-03 12:35:38 -04001335 "Connecting to controller... ws://{} as {}/{}".format(
Adam Israel5e08a0e2018-09-06 19:22:47 -04001336 self.endpoint,
Adam Israel5e08a0e2018-09-06 19:22:47 -04001337 self.user,
1338 self.secret,
1339 )
1340 )
Adam Israel19c5cfc2019-10-03 12:35:38 -04001341 try:
1342 await self.controller.connect(
1343 endpoint=self.endpoint,
1344 username=self.user,
1345 password=self.secret,
1346 cacert=self.ca_cert,
1347 )
1348 self.refcount['controller'] += 1
1349 self.authenticated = True
1350 self.log.debug("JujuApi: Logged into controller")
1351 except Exception as ex:
1352 self.log.debug("Caught exception: {}".format(ex))
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001353 else:
1354 # current_controller no longer exists
1355 # self.log.debug("Connecting to current controller...")
1356 # await self.controller.connect_current()
Adam Israel88a49632018-04-10 13:04:57 -06001357 # await self.controller.connect(
1358 # endpoint=self.endpoint,
1359 # username=self.user,
1360 # cacert=cacert,
1361 # )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001362 self.log.fatal("VCA credentials not configured.")
Adam Israel19c5cfc2019-10-03 12:35:38 -04001363 self.authenticated = False
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001364
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001365
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001366 async def logout(self):
1367 """Logout of the Juju controller."""
1368 if not self.authenticated:
Adam Israel6d84dbd2019-03-08 18:33:35 -05001369 return False
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001370
1371 try:
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001372 for model in self.models:
Adam Israel85a4b212018-11-29 20:30:24 -05001373 await self.disconnect_model(model)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001374
1375 if self.controller:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001376 self.log.debug("Disconnecting controller {}".format(
1377 self.controller
1378 ))
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001379 await self.controller.disconnect()
Adam Israelfc511ed2018-09-21 14:20:55 +02001380 self.refcount['controller'] -= 1
Adam Israel5e08a0e2018-09-06 19:22:47 -04001381 self.controller = None
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001382
1383 self.authenticated = False
Adam Israelfc511ed2018-09-21 14:20:55 +02001384
1385 self.log.debug(self.refcount)
1386
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001387 except Exception as e:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001388 self.log.fatal(
1389 "Fatal error logging out of Juju Controller: {}".format(e)
1390 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001391 raise e
Adam Israel6d84dbd2019-03-08 18:33:35 -05001392 return True
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001393
Adam Israel85a4b212018-11-29 20:30:24 -05001394 async def disconnect_model(self, model):
1395 self.log.debug("Disconnecting model {}".format(model))
1396 if model in self.models:
Adam Israel0cd1c022019-09-03 18:26:08 -04001397 try:
1398 await self.models[model].disconnect()
1399 self.refcount['model'] -= 1
1400 self.models[model] = None
1401 except Exception as e:
1402 self.log.debug("Caught exception: {}".format(e))
1403
1404 async def provision_machine(self, model_name: str,
1405 hostname: str, username: str,
1406 private_key_path: str) -> int:
1407 """Provision a machine.
1408
1409 This executes the SSH provisioner, which will log in to a machine via
1410 SSH and prepare it for use with the Juju model
1411
1412 :param model_name str: The name of the model
1413 :param hostname str: The IP or hostname of the target VM
1414 :param user str: The username to login to
1415 :param private_key_path str: The path to the private key that's been injected to the VM via cloud-init
1416 :return machine_id int: Returns the id of the machine or None if provisioning fails
1417 """
1418 if not self.authenticated:
1419 await self.login()
1420
1421 machine_id = None
1422
1423 if self.api_proxy:
1424 self.log.debug("Instantiating SSH Provisioner for {}@{} ({})".format(
1425 username,
1426 hostname,
1427 private_key_path
1428 ))
1429 provisioner = SSHProvisioner(
1430 host=hostname,
1431 user=username,
1432 private_key_path=private_key_path,
1433 log=self.log,
1434 )
1435
1436 params = None
1437 try:
1438 params = provisioner.provision_machine()
1439 except Exception as ex:
1440 self.log.debug("caught exception from provision_machine: {}".format(ex))
1441 return None
1442
1443 if params:
1444 params.jobs = ['JobHostUnits']
1445
1446 model = await self.get_model(model_name)
1447
1448 connection = model.connection()
1449
1450 # Submit the request.
1451 self.log.debug("Adding machine to model")
1452 client_facade = client.ClientFacade.from_connection(connection)
1453 results = await client_facade.AddMachines(params=[params])
1454 error = results.machines[0].error
1455 if error:
1456 raise ValueError("Error adding machine: %s" % error.message)
1457
1458 machine_id = results.machines[0].machine
1459
1460 # Need to run this after AddMachines has been called,
1461 # as we need the machine_id
1462 self.log.debug("Installing Juju agent")
1463 await provisioner.install_agent(
1464 connection,
1465 params.nonce,
1466 machine_id,
1467 self.api_proxy,
1468 )
1469 else:
1470 self.log.debug("Missing API Proxy")
1471 return machine_id
Adam Israel85a4b212018-11-29 20:30:24 -05001472
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001473 # async def remove_application(self, name):
1474 # """Remove the application."""
1475 # if not self.authenticated:
1476 # await self.login()
1477 #
1478 # app = await self.get_application(name)
1479 # if app:
1480 # self.log.debug("JujuApi: Destroying application {}".format(
1481 # name,
1482 # ))
1483 #
1484 # await app.destroy()
1485
1486 async def remove_relation(self, a, b):
1487 """
1488 Remove a relation between two application endpoints
1489
1490 :param a An application endpoint
1491 :param b An application endpoint
1492 """
1493 if not self.authenticated:
1494 await self.login()
1495
1496 m = await self.get_model()
1497 try:
1498 m.remove_relation(a, b)
1499 finally:
1500 await m.disconnect()
1501
Adam Israel85a4b212018-11-29 20:30:24 -05001502 async def resolve_error(self, model_name, application=None):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001503 """Resolve units in error state."""
1504 if not self.authenticated:
1505 await self.login()
1506
Adam Israel85a4b212018-11-29 20:30:24 -05001507 model = await self.get_model(model_name)
1508
1509 app = await self.get_application(model, application)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001510 if app:
Adam Israel5e08a0e2018-09-06 19:22:47 -04001511 self.log.debug(
1512 "JujuApi: Resolving errors for application {}".format(
1513 application,
1514 )
1515 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001516
1517 for unit in app.units:
1518 app.resolved(retry=True)
1519
Adam Israel85a4b212018-11-29 20:30:24 -05001520 async def run_action(self, model_name, application, action_name, **params):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001521 """Execute an action and return an Action object."""
1522 if not self.authenticated:
1523 await self.login()
1524 result = {
1525 'status': '',
1526 'action': {
1527 'tag': None,
1528 'results': None,
1529 }
1530 }
Adam Israel85a4b212018-11-29 20:30:24 -05001531
1532 model = await self.get_model(model_name)
1533
1534 app = await self.get_application(model, application)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001535 if app:
1536 # We currently only have one unit per application
1537 # so use the first unit available.
1538 unit = app.units[0]
1539
Adam Israel5e08a0e2018-09-06 19:22:47 -04001540 self.log.debug(
1541 "JujuApi: Running Action {} against Application {}".format(
1542 action_name,
1543 application,
1544 )
1545 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001546
1547 action = await unit.run_action(action_name, **params)
1548
1549 # Wait for the action to complete
1550 await action.wait()
1551
1552 result['status'] = action.status
1553 result['action']['tag'] = action.data['id']
1554 result['action']['results'] = action.results
1555
1556 return result
1557
Adam Israelb5214512018-05-03 10:00:04 -04001558 async def set_config(self, model_name, application, config):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001559 """Apply a configuration to the application."""
1560 if not self.authenticated:
1561 await self.login()
1562
Adam Israelb5214512018-05-03 10:00:04 -04001563 app = await self.get_application(model_name, application)
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001564 if app:
1565 self.log.debug("JujuApi: Setting config for Application {}".format(
1566 application,
1567 ))
1568 await app.set_config(config)
1569
1570 # Verify the config is set
1571 newconf = await app.get_config()
1572 for key in config:
1573 if config[key] != newconf[key]['value']:
1574 self.log.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key, config[key], newconf[key]))
1575
Adam Israelb5214512018-05-03 10:00:04 -04001576 # async def set_parameter(self, parameter, value, application=None):
1577 # """Set a config parameter for a service."""
1578 # if not self.authenticated:
1579 # await self.login()
1580 #
1581 # self.log.debug("JujuApi: Setting {}={} for Application {}".format(
1582 # parameter,
1583 # value,
1584 # application,
1585 # ))
1586 # return await self.apply_config(
1587 # {parameter: value},
1588 # application=application,
1589 # )
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001590
Adam Israel5e08a0e2018-09-06 19:22:47 -04001591 async def wait_for_application(self, model_name, application_name,
1592 timeout=300):
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001593 """Wait for an application to become active."""
1594 if not self.authenticated:
1595 await self.login()
1596
Adam Israel5e08a0e2018-09-06 19:22:47 -04001597 model = await self.get_model(model_name)
1598
1599 app = await self.get_application(model, application_name)
1600 self.log.debug("Application: {}".format(app))
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001601 if app:
1602 self.log.debug(
1603 "JujuApi: Waiting {} seconds for Application {}".format(
1604 timeout,
Adam Israel5e08a0e2018-09-06 19:22:47 -04001605 application_name,
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001606 )
1607 )
1608
Adam Israel5e08a0e2018-09-06 19:22:47 -04001609 await model.block_until(
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001610 lambda: all(
Adam Israel5e08a0e2018-09-06 19:22:47 -04001611 unit.agent_status == 'idle' and unit.workload_status in
1612 ['active', 'unknown'] for unit in app.units
Adam Israelc3e6c2e2018-03-01 09:31:50 -05001613 ),
1614 timeout=timeout
1615 )