K8s action support
[osm/N2VC.git] / n2vc / n2vc_juju_conn.py
1 ##
2 # Copyright 2019 Telefonica Investigacion y Desarrollo, S.A.U.
3 # This file is part of OSM
4 # All Rights Reserved.
5 #
6 # Licensed under the Apache License, Version 2.0 (the "License");
7 # you may not use this file except in compliance with the License.
8 # You may obtain a copy of the License at
9 #
10 # http://www.apache.org/licenses/LICENSE-2.0
11 #
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS,
14 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
15 # implied.
16 # See the License for the specific language governing permissions and
17 # limitations under the License.
18 #
19 # For those usages not covered by the Apache License, Version 2.0 please
20 # contact with: nfvlabs@tid.es
21 ##
22
23 import logging
24 import os
25 import asyncio
26 import time
27 import base64
28 import binascii
29 import re
30
31 from n2vc.n2vc_conn import N2VCConnector
32 from n2vc.n2vc_conn import obj_to_dict, obj_to_yaml
33 from n2vc.exceptions \
34 import N2VCBadArgumentsException, N2VCException, N2VCConnectionException, \
35 N2VCExecutionException, N2VCInvalidCertificate, N2VCNotFound
36 from n2vc.juju_observer import JujuModelObserver
37
38 from juju.controller import Controller
39 from juju.model import Model
40 from juju.application import Application
41 from juju.action import Action
42 from juju.machine import Machine
43 from juju.client import client
44 from juju.errors import JujuAPIError
45
46 from n2vc.provisioner import SSHProvisioner
47
48
49 class N2VCJujuConnector(N2VCConnector):
50
51 """
52 ##################################################################################################
53 ########################################## P U B L I C ###########################################
54 ##################################################################################################
55 """
56
57 def __init__(
58 self,
59 db: object,
60 fs: object,
61 log: object = None,
62 loop: object = None,
63 url: str = '127.0.0.1:17070',
64 username: str = 'admin',
65 vca_config: dict = None,
66 on_update_db=None
67 ):
68 """Initialize juju N2VC connector
69 """
70
71 # parent class constructor
72 N2VCConnector.__init__(
73 self,
74 db=db,
75 fs=fs,
76 log=log,
77 loop=loop,
78 url=url,
79 username=username,
80 vca_config=vca_config,
81 on_update_db=on_update_db
82 )
83
84 # silence websocket traffic log
85 logging.getLogger('websockets.protocol').setLevel(logging.INFO)
86 logging.getLogger('juju.client.connection').setLevel(logging.WARN)
87 logging.getLogger('model').setLevel(logging.WARN)
88
89 self.log.info('Initializing N2VC juju connector...')
90
91 """
92 ##############################################################
93 # check arguments
94 ##############################################################
95 """
96
97 # juju URL
98 if url is None:
99 raise N2VCBadArgumentsException('Argument url is mandatory', ['url'])
100 url_parts = url.split(':')
101 if len(url_parts) != 2:
102 raise N2VCBadArgumentsException('Argument url: bad format (localhost:port) -> {}'.format(url), ['url'])
103 self.hostname = url_parts[0]
104 try:
105 self.port = int(url_parts[1])
106 except ValueError:
107 raise N2VCBadArgumentsException('url port must be a number -> {}'.format(url), ['url'])
108
109 # juju USERNAME
110 if username is None:
111 raise N2VCBadArgumentsException('Argument username is mandatory', ['username'])
112
113 # juju CONFIGURATION
114 if vca_config is None:
115 raise N2VCBadArgumentsException('Argument vca_config is mandatory', ['vca_config'])
116
117 if 'secret' in vca_config:
118 self.secret = vca_config['secret']
119 else:
120 raise N2VCBadArgumentsException('Argument vca_config.secret is mandatory', ['vca_config.secret'])
121
122 # pubkey of juju client in osm machine: ~/.local/share/juju/ssh/juju_id_rsa.pub
123 # if exists, it will be written in lcm container: _create_juju_public_key()
124 if 'public_key' in vca_config:
125 self.public_key = vca_config['public_key']
126 else:
127 self.public_key = None
128
129 # TODO: Verify ca_cert is valid before using. VCA will crash
130 # if the ca_cert isn't formatted correctly.
131 def base64_to_cacert(b64string):
132 """Convert the base64-encoded string containing the VCA CACERT.
133
134 The input string....
135
136 """
137 try:
138 cacert = base64.b64decode(b64string).decode("utf-8")
139
140 cacert = re.sub(
141 r'\\n',
142 r'\n',
143 cacert,
144 )
145 except binascii.Error as e:
146 self.log.debug("Caught binascii.Error: {}".format(e))
147 raise N2VCInvalidCertificate(message="Invalid CA Certificate")
148
149 return cacert
150
151 self.ca_cert = vca_config.get('ca_cert')
152 if self.ca_cert:
153 self.ca_cert = base64_to_cacert(vca_config['ca_cert'])
154
155 if 'api_proxy' in vca_config:
156 self.api_proxy = vca_config['api_proxy']
157 self.log.debug('api_proxy for native charms configured: {}'.format(self.api_proxy))
158 else:
159 self.warning('api_proxy is not configured. Support for native charms is disabled')
160
161 if 'enable_os_upgrade' in vca_config:
162 self.enable_os_upgrade = vca_config['enable_os_upgrade']
163 else:
164 self.enable_os_upgrade = True
165
166 if 'apt_mirror' in vca_config:
167 self.apt_mirror = vca_config['apt_mirror']
168 else:
169 self.apt_mirror = None
170
171 self.cloud = vca_config.get('cloud')
172 # self.log.debug('Arguments have been checked')
173
174 # juju data
175 self.controller = None # it will be filled when connect to juju
176 self.juju_models = {} # model objects for every model_name
177 self.juju_observers = {} # model observers for every model_name
178 self._connecting = False # while connecting to juju (to avoid duplicate connections)
179 self._authenticated = False # it will be True when juju connection be stablished
180 self._creating_model = False # True during model creation
181
182 # create juju pub key file in lcm container at ./local/share/juju/ssh/juju_id_rsa.pub
183 self._create_juju_public_key()
184
185 self.log.info('N2VC juju connector initialized')
186
187 async def get_status(self, namespace: str, yaml_format: bool = True):
188
189 # self.log.info('Getting NS status. namespace: {}'.format(namespace))
190
191 if not self._authenticated:
192 await self._juju_login()
193
194 nsi_id, ns_id, vnf_id, vdu_id, vdu_count = self._get_namespace_components(namespace=namespace)
195 # model name is ns_id
196 model_name = ns_id
197 if model_name is None:
198 msg = 'Namespace {} not valid'.format(namespace)
199 self.log.error(msg)
200 raise N2VCBadArgumentsException(msg, ['namespace'])
201
202 # get juju model (create model if needed)
203 model = await self._juju_get_model(model_name=model_name)
204
205 status = await model.get_status()
206
207 if yaml_format:
208 return obj_to_yaml(status)
209 else:
210 return obj_to_dict(status)
211
212 async def create_execution_environment(
213 self,
214 namespace: str,
215 db_dict: dict,
216 reuse_ee_id: str = None,
217 progress_timeout: float = None,
218 total_timeout: float = None
219 ) -> (str, dict):
220
221 self.log.info('Creating execution environment. namespace: {}, reuse_ee_id: {}'.format(namespace, reuse_ee_id))
222
223 if not self._authenticated:
224 await self._juju_login()
225
226 machine_id = None
227 if reuse_ee_id:
228 model_name, application_name, machine_id = self._get_ee_id_components(ee_id=reuse_ee_id)
229 else:
230 nsi_id, ns_id, vnf_id, vdu_id, vdu_count = self._get_namespace_components(namespace=namespace)
231 # model name is ns_id
232 model_name = ns_id
233 # application name
234 application_name = self._get_application_name(namespace=namespace)
235
236 self.log.debug('model name: {}, application name: {}, machine_id: {}'
237 .format(model_name, application_name, machine_id))
238
239 # create or reuse a new juju machine
240 try:
241 machine = await self._juju_create_machine(
242 model_name=model_name,
243 application_name=application_name,
244 machine_id=machine_id,
245 db_dict=db_dict,
246 progress_timeout=progress_timeout,
247 total_timeout=total_timeout
248 )
249 except Exception as e:
250 message = 'Error creating machine on juju: {}'.format(e)
251 self.log.error(message)
252 raise N2VCException(message=message)
253
254 # id for the execution environment
255 ee_id = N2VCJujuConnector._build_ee_id(
256 model_name=model_name,
257 application_name=application_name,
258 machine_id=str(machine.entity_id)
259 )
260 self.log.debug('ee_id: {}'.format(ee_id))
261
262 # new machine credentials
263 credentials = dict()
264 credentials['hostname'] = machine.dns_name
265
266 self.log.info('Execution environment created. ee_id: {}, credentials: {}'.format(ee_id, credentials))
267
268 return ee_id, credentials
269
270 async def register_execution_environment(
271 self,
272 namespace: str,
273 credentials: dict,
274 db_dict: dict,
275 progress_timeout: float = None,
276 total_timeout: float = None
277 ) -> str:
278
279 if not self._authenticated:
280 await self._juju_login()
281
282 self.log.info('Registering execution environment. namespace={}, credentials={}'.format(namespace, credentials))
283
284 if credentials is None:
285 raise N2VCBadArgumentsException(message='credentials are mandatory', bad_args=['credentials'])
286 if credentials.get('hostname'):
287 hostname = credentials['hostname']
288 else:
289 raise N2VCBadArgumentsException(message='hostname is mandatory', bad_args=['credentials.hostname'])
290 if credentials.get('username'):
291 username = credentials['username']
292 else:
293 raise N2VCBadArgumentsException(message='username is mandatory', bad_args=['credentials.username'])
294 if 'private_key_path' in credentials:
295 private_key_path = credentials['private_key_path']
296 else:
297 # if not passed as argument, use generated private key path
298 private_key_path = self.private_key_path
299
300 nsi_id, ns_id, vnf_id, vdu_id, vdu_count = self._get_namespace_components(namespace=namespace)
301
302 # model name
303 model_name = ns_id
304 # application name
305 application_name = self._get_application_name(namespace=namespace)
306
307 # register machine on juju
308 try:
309 machine_id = await self._juju_provision_machine(
310 model_name=model_name,
311 hostname=hostname,
312 username=username,
313 private_key_path=private_key_path,
314 db_dict=db_dict,
315 progress_timeout=progress_timeout,
316 total_timeout=total_timeout
317 )
318 except Exception as e:
319 self.log.error('Error registering machine: {}'.format(e))
320 raise N2VCException(message='Error registering machine on juju: {}'.format(e))
321
322 self.log.info('Machine registered: {}'.format(machine_id))
323
324 # id for the execution environment
325 ee_id = N2VCJujuConnector._build_ee_id(
326 model_name=model_name,
327 application_name=application_name,
328 machine_id=str(machine_id)
329 )
330
331 self.log.info('Execution environment registered. ee_id: {}'.format(ee_id))
332
333 return ee_id
334
335 async def install_configuration_sw(
336 self,
337 ee_id: str,
338 artifact_path: str,
339 db_dict: dict,
340 progress_timeout: float = None,
341 total_timeout: float = None,
342 config: dict = None,
343 ):
344
345 self.log.info('Installing configuration sw on ee_id: {}, artifact path: {}, db_dict: {}'
346 .format(ee_id, artifact_path, db_dict))
347
348 if not self._authenticated:
349 await self._juju_login()
350
351 # check arguments
352 if ee_id is None or len(ee_id) == 0:
353 raise N2VCBadArgumentsException(message='ee_id is mandatory', bad_args=['ee_id'])
354 if artifact_path is None or len(artifact_path) == 0:
355 raise N2VCBadArgumentsException(message='artifact_path is mandatory', bad_args=['artifact_path'])
356 if db_dict is None:
357 raise N2VCBadArgumentsException(message='db_dict is mandatory', bad_args=['db_dict'])
358
359 try:
360 model_name, application_name, machine_id = N2VCJujuConnector._get_ee_id_components(ee_id=ee_id)
361 self.log.debug('model: {}, application: {}, machine: {}'.format(model_name, application_name, machine_id))
362 except Exception as e:
363 raise N2VCBadArgumentsException(
364 message='ee_id={} is not a valid execution environment id'.format(ee_id),
365 bad_args=['ee_id']
366 )
367
368 # remove // in charm path
369 while artifact_path.find('//') >= 0:
370 artifact_path = artifact_path.replace('//', '/')
371
372 # check charm path
373 if not self.fs.file_exists(artifact_path, mode="dir"):
374 msg = 'artifact path does not exist: {}'.format(artifact_path)
375 raise N2VCBadArgumentsException(message=msg, bad_args=['artifact_path'])
376
377 if artifact_path.startswith('/'):
378 full_path = self.fs.path + artifact_path
379 else:
380 full_path = self.fs.path + '/' + artifact_path
381
382 try:
383 application, retries = await self._juju_deploy_charm(
384 model_name=model_name,
385 application_name=application_name,
386 charm_path=full_path,
387 machine_id=machine_id,
388 db_dict=db_dict,
389 progress_timeout=progress_timeout,
390 total_timeout=total_timeout,
391 config=config
392 )
393 except Exception as e:
394 raise N2VCException(message='Error desploying charm into ee={} : {}'.format(ee_id, e))
395
396 self.log.info('Configuration sw installed')
397
398 async def get_ee_ssh_public__key(
399 self,
400 ee_id: str,
401 db_dict: dict,
402 progress_timeout: float = None,
403 total_timeout: float = None
404 ) -> str:
405
406 self.log.info('Generating priv/pub key pair and get pub key on ee_id: {}, db_dict: {}'.format(ee_id, db_dict))
407
408 if not self._authenticated:
409 await self._juju_login()
410
411 # check arguments
412 if ee_id is None or len(ee_id) == 0:
413 raise N2VCBadArgumentsException(message='ee_id is mandatory', bad_args=['ee_id'])
414 if db_dict is None:
415 raise N2VCBadArgumentsException(message='db_dict is mandatory', bad_args=['db_dict'])
416
417 try:
418 model_name, application_name, machine_id = N2VCJujuConnector._get_ee_id_components(ee_id=ee_id)
419 self.log.debug('model: {}, application: {}, machine: {}'.format(model_name, application_name, machine_id))
420 except Exception as e:
421 raise N2VCBadArgumentsException(
422 message='ee_id={} is not a valid execution environment id'.format(ee_id),
423 bad_args=['ee_id']
424 )
425
426 # try to execute ssh layer primitives (if exist):
427 # generate-ssh-key
428 # get-ssh-public-key
429
430 output = None
431
432 # execute action: generate-ssh-key
433 try:
434 output, status = await self._juju_execute_action(
435 model_name=model_name,
436 application_name=application_name,
437 action_name='generate-ssh-key',
438 db_dict=db_dict,
439 progress_timeout=progress_timeout,
440 total_timeout=total_timeout
441 )
442 except Exception as e:
443 self.log.info('Skipping exception while executing action generate-ssh-key: {}'.format(e))
444
445 # execute action: get-ssh-public-key
446 try:
447 output, status = await self._juju_execute_action(
448 model_name=model_name,
449 application_name=application_name,
450 action_name='get-ssh-public-key',
451 db_dict=db_dict,
452 progress_timeout=progress_timeout,
453 total_timeout=total_timeout
454 )
455 except Exception as e:
456 msg = 'Cannot execute action get-ssh-public-key: {}\n'.format(e)
457 self.log.info(msg)
458 raise N2VCException(msg)
459
460 # return public key if exists
461 return output["pubkey"] if "pubkey" in output else output
462
463 async def add_relation(
464 self,
465 ee_id_1: str,
466 ee_id_2: str,
467 endpoint_1: str,
468 endpoint_2: str
469 ):
470
471 self.log.debug('adding new relation between {} and {}, endpoints: {}, {}'
472 .format(ee_id_1, ee_id_2, endpoint_1, endpoint_2))
473
474 # check arguments
475 if not ee_id_1:
476 message = 'EE 1 is mandatory'
477 self.log.error(message)
478 raise N2VCBadArgumentsException(message=message, bad_args=['ee_id_1'])
479 if not ee_id_2:
480 message = 'EE 2 is mandatory'
481 self.log.error(message)
482 raise N2VCBadArgumentsException(message=message, bad_args=['ee_id_2'])
483 if not endpoint_1:
484 message = 'endpoint 1 is mandatory'
485 self.log.error(message)
486 raise N2VCBadArgumentsException(message=message, bad_args=['endpoint_1'])
487 if not endpoint_2:
488 message = 'endpoint 2 is mandatory'
489 self.log.error(message)
490 raise N2VCBadArgumentsException(message=message, bad_args=['endpoint_2'])
491
492 if not self._authenticated:
493 await self._juju_login()
494
495 # get the model, the applications and the machines from the ee_id's
496 model_1, app_1, machine_1 = self._get_ee_id_components(ee_id_1)
497 model_2, app_2, machine_2 = self._get_ee_id_components(ee_id_2)
498
499 # model must be the same
500 if model_1 != model_2:
501 message = 'EE models are not the same: {} vs {}'.format(ee_id_1, ee_id_2)
502 self.log.error(message)
503 raise N2VCBadArgumentsException(message=message, bad_args=['ee_id_1', 'ee_id_2'])
504
505 # add juju relations between two applications
506 try:
507 await self._juju_add_relation(
508 model_name=model_1,
509 application_name_1=app_1,
510 application_name_2=app_2,
511 relation_1=endpoint_1,
512 relation_2=endpoint_2
513 )
514 except Exception as e:
515 message = 'Error adding relation between {} and {}: {}'.format(ee_id_1, ee_id_2, e)
516 self.log.error(message)
517 raise N2VCException(message=message)
518
519 async def remove_relation(
520 self
521 ):
522 if not self._authenticated:
523 await self._juju_login()
524 # TODO
525 self.log.info('Method not implemented yet')
526 raise NotImplemented()
527
528 async def deregister_execution_environments(
529 self
530 ):
531 if not self._authenticated:
532 await self._juju_login()
533 # TODO
534 self.log.info('Method not implemented yet')
535 raise NotImplemented()
536
537 async def delete_namespace(
538 self,
539 namespace: str,
540 db_dict: dict = None,
541 total_timeout: float = None
542 ):
543 self.log.info('Deleting namespace={}'.format(namespace))
544
545 if not self._authenticated:
546 await self._juju_login()
547
548 # check arguments
549 if namespace is None:
550 raise N2VCBadArgumentsException(message='namespace is mandatory', bad_args=['namespace'])
551
552 nsi_id, ns_id, vnf_id, vdu_id, vdu_count = self._get_namespace_components(namespace=namespace)
553 if ns_id is not None:
554 try:
555 await self._juju_destroy_model(
556 model_name=ns_id,
557 total_timeout=total_timeout
558 )
559 except N2VCNotFound:
560 raise
561 except Exception as e:
562 raise N2VCException(message='Error deleting namespace {} : {}'.format(namespace, e))
563 else:
564 raise N2VCBadArgumentsException(message='only ns_id is permitted to delete yet', bad_args=['namespace'])
565
566 self.log.info('Namespace {} deleted'.format(namespace))
567
568 async def delete_execution_environment(
569 self,
570 ee_id: str,
571 db_dict: dict = None,
572 total_timeout: float = None
573 ):
574 self.log.info('Deleting execution environment ee_id={}'.format(ee_id))
575
576 if not self._authenticated:
577 await self._juju_login()
578
579 # check arguments
580 if ee_id is None:
581 raise N2VCBadArgumentsException(message='ee_id is mandatory', bad_args=['ee_id'])
582
583 model_name, application_name, machine_id = self._get_ee_id_components(ee_id=ee_id)
584
585 # destroy the application
586 try:
587 await self._juju_destroy_application(model_name=model_name, application_name=application_name)
588 except Exception as e:
589 raise N2VCException(message='Error deleting execution environment {} (application {}) : {}'
590 .format(ee_id, application_name, e))
591
592 # destroy the machine
593 # try:
594 # await self._juju_destroy_machine(
595 # model_name=model_name,
596 # machine_id=machine_id,
597 # total_timeout=total_timeout
598 # )
599 # except Exception as e:
600 # raise N2VCException(message='Error deleting execution environment {} (machine {}) : {}'
601 # .format(ee_id, machine_id, e))
602
603 self.log.info('Execution environment {} deleted'.format(ee_id))
604
605 async def exec_primitive(
606 self,
607 ee_id: str,
608 primitive_name: str,
609 params_dict: dict,
610 db_dict: dict = None,
611 progress_timeout: float = None,
612 total_timeout: float = None
613 ) -> str:
614
615 self.log.info('Executing primitive: {} on ee: {}, params: {}'.format(primitive_name, ee_id, params_dict))
616
617 if not self._authenticated:
618 await self._juju_login()
619
620 # check arguments
621 if ee_id is None or len(ee_id) == 0:
622 raise N2VCBadArgumentsException(message='ee_id is mandatory', bad_args=['ee_id'])
623 if primitive_name is None or len(primitive_name) == 0:
624 raise N2VCBadArgumentsException(message='action_name is mandatory', bad_args=['action_name'])
625 if params_dict is None:
626 params_dict = dict()
627
628 try:
629 model_name, application_name, machine_id = N2VCJujuConnector._get_ee_id_components(ee_id=ee_id)
630 except Exception:
631 raise N2VCBadArgumentsException(
632 message='ee_id={} is not a valid execution environment id'.format(ee_id),
633 bad_args=['ee_id']
634 )
635
636 if primitive_name == 'config':
637 # Special case: config primitive
638 try:
639 await self._juju_configure_application(
640 model_name=model_name,
641 application_name=application_name,
642 config=params_dict,
643 db_dict=db_dict,
644 progress_timeout=progress_timeout,
645 total_timeout=total_timeout
646 )
647 except Exception as e:
648 self.log.error('Error configuring juju application: {}'.format(e))
649 raise N2VCExecutionException(
650 message='Error configuring application into ee={} : {}'.format(ee_id, e),
651 primitive_name=primitive_name
652 )
653 return 'CONFIG OK'
654 else:
655 try:
656 output, status = await self._juju_execute_action(
657 model_name=model_name,
658 application_name=application_name,
659 action_name=primitive_name,
660 db_dict=db_dict,
661 progress_timeout=progress_timeout,
662 total_timeout=total_timeout,
663 **params_dict
664 )
665 if status == 'completed':
666 return output
667 else:
668 raise Exception('status is not completed: {}'.format(status))
669 except Exception as e:
670 self.log.error('Error executing primitive {}: {}'.format(primitive_name, e))
671 raise N2VCExecutionException(
672 message='Error executing primitive {} into ee={} : {}'.format(primitive_name, ee_id, e),
673 primitive_name=primitive_name
674 )
675
676 async def disconnect(self):
677 self.log.info('closing juju N2VC...')
678 await self._juju_logout()
679
680 """
681 ##################################################################################################
682 ########################################## P R I V A T E #########################################
683 ##################################################################################################
684 """
685
686 def _write_ee_id_db(
687 self,
688 db_dict: dict,
689 ee_id: str
690 ):
691
692 # write ee_id to database: _admin.deployed.VCA.x
693 try:
694 the_table = db_dict['collection']
695 the_filter = db_dict['filter']
696 the_path = db_dict['path']
697 if not the_path[-1] == '.':
698 the_path = the_path + '.'
699 update_dict = {the_path + 'ee_id': ee_id}
700 # self.log.debug('Writing ee_id to database: {}'.format(the_path))
701 self.db.set_one(
702 table=the_table,
703 q_filter=the_filter,
704 update_dict=update_dict,
705 fail_on_empty=True
706 )
707 except asyncio.CancelledError:
708 raise
709 except Exception as e:
710 self.log.error('Error writing ee_id to database: {}'.format(e))
711
712 @staticmethod
713 def _build_ee_id(
714 model_name: str,
715 application_name: str,
716 machine_id: str
717 ):
718 """
719 Build an execution environment id form model, application and machine
720 :param model_name:
721 :param application_name:
722 :param machine_id:
723 :return:
724 """
725 # id for the execution environment
726 return '{}.{}.{}'.format(model_name, application_name, machine_id)
727
728 @staticmethod
729 def _get_ee_id_components(
730 ee_id: str
731 ) -> (str, str, str):
732 """
733 Get model, application and machine components from an execution environment id
734 :param ee_id:
735 :return: model_name, application_name, machine_id
736 """
737
738 if ee_id is None:
739 return None, None, None
740
741 # split components of id
742 parts = ee_id.split('.')
743 model_name = parts[0]
744 application_name = parts[1]
745 machine_id = parts[2]
746 return model_name, application_name, machine_id
747
748 def _get_application_name(self, namespace: str) -> str:
749 """
750 Build application name from namespace
751 :param namespace:
752 :return: app-vnf-<vnf id>-vdu-<vdu-id>-cnt-<vdu-count>
753 """
754
755 # TODO: Enforce the Juju 50-character application limit
756
757 # split namespace components
758 _, _, vnf_id, vdu_id, vdu_count = self._get_namespace_components(namespace=namespace)
759
760 if vnf_id is None or len(vnf_id) == 0:
761 vnf_id = ''
762 else:
763 # Shorten the vnf_id to its last twelve characters
764 vnf_id = 'vnf-' + vnf_id[-12:]
765
766 if vdu_id is None or len(vdu_id) == 0:
767 vdu_id = ''
768 else:
769 # Shorten the vdu_id to its last twelve characters
770 vdu_id = '-vdu-' + vdu_id[-12:]
771
772 if vdu_count is None or len(vdu_count) == 0:
773 vdu_count = ''
774 else:
775 vdu_count = '-cnt-' + vdu_count
776
777 application_name = 'app-{}{}{}'.format(vnf_id, vdu_id, vdu_count)
778
779 return N2VCJujuConnector._format_app_name(application_name)
780
781 async def _juju_create_machine(
782 self,
783 model_name: str,
784 application_name: str,
785 machine_id: str = None,
786 db_dict: dict = None,
787 progress_timeout: float = None,
788 total_timeout: float = None
789 ) -> Machine:
790
791 self.log.debug('creating machine in model: {}, existing machine id: {}'.format(model_name, machine_id))
792
793 # get juju model and observer (create model if needed)
794 model = await self._juju_get_model(model_name=model_name)
795 observer = self.juju_observers[model_name]
796
797 # find machine id in model
798 machine = None
799 if machine_id is not None:
800 self.log.debug('Finding existing machine id {} in model'.format(machine_id))
801 # get juju existing machines in the model
802 existing_machines = await model.get_machines()
803 if machine_id in existing_machines:
804 self.log.debug('Machine id {} found in model (reusing it)'.format(machine_id))
805 machine = model.machines[machine_id]
806
807 if machine is None:
808 self.log.debug('Creating a new machine in juju...')
809 # machine does not exist, create it and wait for it
810 machine = await model.add_machine(
811 spec=None,
812 constraints=None,
813 disks=None,
814 series='xenial'
815 )
816
817 # register machine with observer
818 observer.register_machine(machine=machine, db_dict=db_dict)
819
820 # id for the execution environment
821 ee_id = N2VCJujuConnector._build_ee_id(
822 model_name=model_name,
823 application_name=application_name,
824 machine_id=str(machine.entity_id)
825 )
826
827 # write ee_id in database
828 self._write_ee_id_db(
829 db_dict=db_dict,
830 ee_id=ee_id
831 )
832
833 # wait for machine creation
834 await observer.wait_for_machine(
835 machine_id=str(machine.entity_id),
836 progress_timeout=progress_timeout,
837 total_timeout=total_timeout
838 )
839
840 else:
841
842 self.log.debug('Reusing old machine pending')
843
844 # register machine with observer
845 observer.register_machine(machine=machine, db_dict=db_dict)
846
847 # machine does exist, but it is in creation process (pending), wait for create finalisation
848 await observer.wait_for_machine(
849 machine_id=machine.entity_id,
850 progress_timeout=progress_timeout,
851 total_timeout=total_timeout)
852
853 self.log.debug("Machine ready at " + str(machine.dns_name))
854 return machine
855
856 async def _juju_provision_machine(
857 self,
858 model_name: str,
859 hostname: str,
860 username: str,
861 private_key_path: str,
862 db_dict: dict = None,
863 progress_timeout: float = None,
864 total_timeout: float = None
865 ) -> str:
866
867 if not self.api_proxy:
868 msg = 'Cannot provision machine: api_proxy is not defined'
869 self.log.error(msg=msg)
870 raise N2VCException(message=msg)
871
872 self.log.debug('provisioning machine. model: {}, hostname: {}, username: {}'.format(model_name, hostname, username))
873
874 if not self._authenticated:
875 await self._juju_login()
876
877 # get juju model and observer
878 model = await self._juju_get_model(model_name=model_name)
879 observer = self.juju_observers[model_name]
880
881 # TODO check if machine is already provisioned
882 machine_list = await model.get_machines()
883
884 provisioner = SSHProvisioner(
885 host=hostname,
886 user=username,
887 private_key_path=private_key_path,
888 log=self.log
889 )
890
891 params = None
892 try:
893 params = provisioner.provision_machine()
894 except Exception as ex:
895 msg = "Exception provisioning machine: {}".format(ex)
896 self.log.error(msg)
897 raise N2VCException(message=msg)
898
899 params.jobs = ['JobHostUnits']
900
901 connection = model.connection()
902
903 # Submit the request.
904 self.log.debug("Adding machine to model")
905 client_facade = client.ClientFacade.from_connection(connection)
906 results = await client_facade.AddMachines(params=[params])
907 error = results.machines[0].error
908 if error:
909 msg = "Error adding machine: {}}".format(error.message)
910 self.log.error(msg=msg)
911 raise ValueError(msg)
912
913 machine_id = results.machines[0].machine
914
915 # Need to run this after AddMachines has been called,
916 # as we need the machine_id
917 self.log.debug("Installing Juju agent into machine {}".format(machine_id))
918 asyncio.ensure_future(provisioner.install_agent(
919 connection=connection,
920 nonce=params.nonce,
921 machine_id=machine_id,
922 api=self.api_proxy,
923 ))
924
925 # wait for machine in model (now, machine is not yet in model, so we must wait for it)
926 machine = None
927 for i in range(10):
928 machine_list = await model.get_machines()
929 if machine_id in machine_list:
930 self.log.debug('Machine {} found in model!'.format(machine_id))
931 machine = model.machines.get(machine_id)
932 break
933 await asyncio.sleep(2)
934
935 if machine is None:
936 msg = 'Machine {} not found in model'.format(machine_id)
937 self.log.error(msg=msg)
938 raise Exception(msg)
939
940 # register machine with observer
941 observer.register_machine(machine=machine, db_dict=db_dict)
942
943 # wait for machine creation
944 self.log.debug('waiting for provision finishes... {}'.format(machine_id))
945 await observer.wait_for_machine(
946 machine_id=machine_id,
947 progress_timeout=progress_timeout,
948 total_timeout=total_timeout
949 )
950
951 self.log.debug("Machine provisioned {}".format(machine_id))
952
953 return machine_id
954
955 async def _juju_deploy_charm(
956 self,
957 model_name: str,
958 application_name: str,
959 charm_path: str,
960 machine_id: str,
961 db_dict: dict,
962 progress_timeout: float = None,
963 total_timeout: float = None,
964 config: dict = None
965 ) -> (Application, int):
966
967 # get juju model and observer
968 model = await self._juju_get_model(model_name=model_name)
969 observer = self.juju_observers[model_name]
970
971 # check if application already exists
972 application = None
973 if application_name in model.applications:
974 application = model.applications[application_name]
975
976 if application is None:
977
978 # application does not exist, create it and wait for it
979 self.log.debug('deploying application {} to machine {}, model {}'
980 .format(application_name, machine_id, model_name))
981 self.log.debug('charm: {}'.format(charm_path))
982 series = 'xenial'
983 # series = None
984 application = await model.deploy(
985 entity_url=charm_path,
986 application_name=application_name,
987 channel='stable',
988 num_units=1,
989 series=series,
990 to=machine_id,
991 config=config
992 )
993
994 # register application with observer
995 observer.register_application(application=application, db_dict=db_dict)
996
997 self.log.debug('waiting for application deployed... {}'.format(application.entity_id))
998 retries = await observer.wait_for_application(
999 application_id=application.entity_id,
1000 progress_timeout=progress_timeout,
1001 total_timeout=total_timeout)
1002 self.log.debug('application deployed')
1003
1004 else:
1005
1006 # register application with observer
1007 observer.register_application(application=application, db_dict=db_dict)
1008
1009 # application already exists, but not finalised
1010 self.log.debug('application already exists, waiting for deployed...')
1011 retries = await observer.wait_for_application(
1012 application_id=application.entity_id,
1013 progress_timeout=progress_timeout,
1014 total_timeout=total_timeout)
1015 self.log.debug('application deployed')
1016
1017 return application, retries
1018
1019 async def _juju_execute_action(
1020 self,
1021 model_name: str,
1022 application_name: str,
1023 action_name: str,
1024 db_dict: dict,
1025 progress_timeout: float = None,
1026 total_timeout: float = None,
1027 **kwargs
1028 ) -> Action:
1029
1030 # get juju model and observer
1031 model = await self._juju_get_model(model_name=model_name)
1032 observer = self.juju_observers[model_name]
1033
1034 application = await self._juju_get_application(model_name=model_name, application_name=application_name)
1035
1036 unit = None
1037 for u in application.units:
1038 if await u.is_leader_from_status():
1039 unit = u
1040 if unit is not None:
1041 actions = await application.get_actions()
1042 if action_name in actions:
1043 self.log.debug('executing action "{}" using params: {}'.format(action_name, kwargs))
1044 action = await unit.run_action(action_name, **kwargs)
1045
1046 # register action with observer
1047 observer.register_action(action=action, db_dict=db_dict)
1048
1049 await observer.wait_for_action(
1050 action_id=action.entity_id,
1051 progress_timeout=progress_timeout,
1052 total_timeout=total_timeout)
1053 self.log.debug('action completed with status: {}'.format(action.status))
1054 output = await model.get_action_output(action_uuid=action.entity_id)
1055 status = await model.get_action_status(uuid_or_prefix=action.entity_id)
1056 if action.entity_id in status:
1057 status = status[action.entity_id]
1058 else:
1059 status = 'failed'
1060 return output, status
1061
1062 raise N2VCExecutionException(
1063 message='Cannot execute action on charm',
1064 primitive_name=action_name
1065 )
1066
1067 async def _juju_configure_application(
1068 self,
1069 model_name: str,
1070 application_name: str,
1071 config: dict,
1072 db_dict: dict,
1073 progress_timeout: float = None,
1074 total_timeout: float = None
1075 ):
1076
1077 # get the application
1078 application = await self._juju_get_application(model_name=model_name, application_name=application_name)
1079
1080 self.log.debug('configuring the application {} -> {}'.format(application_name, config))
1081 res = await application.set_config(config)
1082 self.log.debug('application {} configured. res={}'.format(application_name, res))
1083
1084 # Verify the config is set
1085 new_conf = await application.get_config()
1086 for key in config:
1087 value = new_conf[key]['value']
1088 self.log.debug(' {} = {}'.format(key, value))
1089 if config[key] != value:
1090 raise N2VCException(
1091 message='key {} is not configured correctly {} != {}'.format(key, config[key], new_conf[key])
1092 )
1093
1094 # check if 'verify-ssh-credentials' action exists
1095 # unit = application.units[0]
1096 actions = await application.get_actions()
1097 if 'verify-ssh-credentials' not in actions:
1098 msg = 'Action verify-ssh-credentials does not exist in application {}'.format(application_name)
1099 self.log.debug(msg=msg)
1100 return False
1101
1102 # execute verify-credentials
1103 num_retries = 20
1104 retry_timeout = 15.0
1105 for i in range(num_retries):
1106 try:
1107 self.log.debug('Executing action verify-ssh-credentials...')
1108 output, ok = await self._juju_execute_action(
1109 model_name=model_name,
1110 application_name=application_name,
1111 action_name='verify-ssh-credentials',
1112 db_dict=db_dict,
1113 progress_timeout=progress_timeout,
1114 total_timeout=total_timeout
1115 )
1116 self.log.debug('Result: {}, output: {}'.format(ok, output))
1117 return True
1118 except asyncio.CancelledError:
1119 raise
1120 except Exception as e:
1121 self.log.debug('Error executing verify-ssh-credentials: {}. Retrying...'.format(e))
1122 await asyncio.sleep(retry_timeout)
1123 else:
1124 self.log.error('Error executing verify-ssh-credentials after {} retries. '.format(num_retries))
1125 return False
1126
1127 async def _juju_get_application(
1128 self,
1129 model_name: str,
1130 application_name: str
1131 ):
1132 """Get the deployed application."""
1133
1134 model = await self._juju_get_model(model_name=model_name)
1135
1136 application_name = N2VCJujuConnector._format_app_name(application_name)
1137
1138 if model.applications and application_name in model.applications:
1139 return model.applications[application_name]
1140 else:
1141 raise N2VCException(message='Cannot get application {} from model {}'.format(application_name, model_name))
1142
1143 async def _juju_get_model(self, model_name: str) -> Model:
1144 """ Get a model object from juju controller
1145 If the model does not exits, it creates it.
1146
1147 :param str model_name: name of the model
1148 :returns Model: model obtained from juju controller or Exception
1149 """
1150
1151 # format model name
1152 model_name = N2VCJujuConnector._format_model_name(model_name)
1153
1154 if model_name in self.juju_models:
1155 return self.juju_models[model_name]
1156
1157 if self._creating_model:
1158 self.log.debug('Another coroutine is creating a model. Wait...')
1159 while self._creating_model:
1160 # another coroutine is creating a model, wait
1161 await asyncio.sleep(0.1)
1162 # retry (perhaps another coroutine has created the model meanwhile)
1163 if model_name in self.juju_models:
1164 return self.juju_models[model_name]
1165
1166 try:
1167 self._creating_model = True
1168
1169 # get juju model names from juju
1170 model_list = await self.controller.list_models()
1171
1172 if model_name not in model_list:
1173 self.log.info('Model {} does not exist. Creating new model...'.format(model_name))
1174 config_dict = {'authorized-keys': self.public_key}
1175 if self.apt_mirror:
1176 config_dict['apt-mirror'] = self.apt_mirror
1177 if not self.enable_os_upgrade:
1178 config_dict['enable-os-refresh-update'] = False
1179 config_dict['enable-os-upgrade'] = False
1180
1181 model = await self.controller.add_model(
1182 model_name=model_name,
1183 config=config_dict,
1184 cloud_name=self.cloud,
1185 )
1186 self.log.info('New model created, name={}'.format(model_name))
1187 else:
1188 self.log.debug('Model already exists in juju. Getting model {}'.format(model_name))
1189 model = await self.controller.get_model(model_name)
1190 self.log.debug('Existing model in juju, name={}'.format(model_name))
1191
1192 self.juju_models[model_name] = model
1193 self.juju_observers[model_name] = JujuModelObserver(n2vc=self, model=model)
1194 return model
1195
1196 except Exception as e:
1197 msg = 'Cannot get model {}. Exception: {}'.format(model_name, e)
1198 self.log.error(msg)
1199 raise N2VCException(msg)
1200 finally:
1201 self._creating_model = False
1202
1203 async def _juju_add_relation(
1204 self,
1205 model_name: str,
1206 application_name_1: str,
1207 application_name_2: str,
1208 relation_1: str,
1209 relation_2: str
1210 ):
1211
1212 # get juju model and observer
1213 model = await self._juju_get_model(model_name=model_name)
1214
1215 r1 = '{}:{}'.format(application_name_1, relation_1)
1216 r2 = '{}:{}'.format(application_name_2, relation_2)
1217
1218 self.log.debug('adding relation: {} -> {}'.format(r1, r2))
1219 try:
1220 await model.add_relation(relation1=r1, relation2=r2)
1221 except JujuAPIError as e:
1222 # If one of the applications in the relationship doesn't exist, or the relation has already been added,
1223 # let the operation fail silently.
1224 if 'not found' in e.message:
1225 return
1226 if 'already exists' in e.message:
1227 return
1228 # another execption, raise it
1229 raise e
1230
1231 async def _juju_destroy_application(
1232 self,
1233 model_name: str,
1234 application_name: str
1235 ):
1236
1237 self.log.debug('Destroying application {} in model {}'.format(application_name, model_name))
1238
1239 # get juju model and observer
1240 model = await self._juju_get_model(model_name=model_name)
1241 observer = self.juju_observers[model_name]
1242
1243 application = model.applications.get(application_name)
1244 if application:
1245 observer.unregister_application(application_name)
1246 await application.destroy()
1247 else:
1248 self.log.debug('Application not found: {}'.format(application_name))
1249
1250 async def _juju_destroy_machine(
1251 self,
1252 model_name: str,
1253 machine_id: str,
1254 total_timeout: float = None
1255 ):
1256
1257 self.log.debug('Destroying machine {} in model {}'.format(machine_id, model_name))
1258
1259 if total_timeout is None:
1260 total_timeout = 3600
1261
1262 # get juju model and observer
1263 model = await self._juju_get_model(model_name=model_name)
1264 observer = self.juju_observers[model_name]
1265
1266 machines = await model.get_machines()
1267 if machine_id in machines:
1268 machine = model.machines[machine_id]
1269 observer.unregister_machine(machine_id)
1270 # TODO: change this by machine.is_manual when this is upstreamed: https://github.com/juju/python-libjuju/pull/396
1271 if "instance-id" in machine.safe_data and machine.safe_data[
1272 "instance-id"
1273 ].startswith("manual:"):
1274 self.log.debug("machine.destroy(force=True) started.")
1275 await machine.destroy(force=True)
1276 self.log.debug("machine.destroy(force=True) passed.")
1277 # max timeout
1278 end = time.time() + total_timeout
1279 # wait for machine removal
1280 machines = await model.get_machines()
1281 while machine_id in machines and time.time() < end:
1282 self.log.debug("Waiting for machine {} is destroyed".format(machine_id))
1283 await asyncio.sleep(0.5)
1284 machines = await model.get_machines()
1285 self.log.debug("Machine destroyed: {}".format(machine_id))
1286 else:
1287 self.log.debug('Machine not found: {}'.format(machine_id))
1288
1289 async def _juju_destroy_model(
1290 self,
1291 model_name: str,
1292 total_timeout: float = None
1293 ):
1294
1295 self.log.debug('Destroying model {}'.format(model_name))
1296
1297 if total_timeout is None:
1298 total_timeout = 3600
1299 end = time.time() + total_timeout
1300
1301 model = await self._juju_get_model(model_name=model_name)
1302
1303 if not model:
1304 raise N2VCNotFound(
1305 message="Model {} does not exist".format(model_name)
1306 )
1307
1308 uuid = model.info.uuid
1309
1310 # destroy applications
1311 for application_name in model.applications:
1312 try:
1313 await self._juju_destroy_application(model_name=model_name, application_name=application_name)
1314 except Exception as e:
1315 self.log.error(
1316 "Error destroying application {} in model {}: {}".format(
1317 application_name,
1318 model_name,
1319 e
1320 )
1321 )
1322
1323 # destroy machines
1324 machines = await model.get_machines()
1325 for machine_id in machines:
1326 try:
1327 await self._juju_destroy_machine(model_name=model_name, machine_id=machine_id)
1328 except asyncio.CancelledError:
1329 raise
1330 except Exception as e:
1331 # ignore exceptions destroying machine
1332 pass
1333
1334 await self._juju_disconnect_model(model_name=model_name)
1335
1336 self.log.debug('destroying model {}...'.format(model_name))
1337 await self.controller.destroy_model(uuid)
1338 # self.log.debug('model destroy requested {}'.format(model_name))
1339
1340 # wait for model is completely destroyed
1341 self.log.debug('Waiting for model {} to be destroyed...'.format(model_name))
1342 last_exception = ''
1343 while time.time() < end:
1344 try:
1345 # await self.controller.get_model(uuid)
1346 models = await self.controller.list_models()
1347 if model_name not in models:
1348 self.log.debug('The model {} ({}) was destroyed'.format(model_name, uuid))
1349 return
1350 except asyncio.CancelledError:
1351 raise
1352 except Exception as e:
1353 last_exception = e
1354 await asyncio.sleep(5)
1355 raise N2VCException("Timeout waiting for model {} to be destroyed {}".format(model_name, last_exception))
1356
1357 async def _juju_login(self):
1358 """Connect to juju controller
1359
1360 """
1361
1362 # if already authenticated, exit function
1363 if self._authenticated:
1364 return
1365
1366 # if connecting, wait for finish
1367 # another task could be trying to connect in parallel
1368 while self._connecting:
1369 await asyncio.sleep(0.1)
1370
1371 # double check after other task has finished
1372 if self._authenticated:
1373 return
1374
1375 try:
1376 self._connecting = True
1377 self.log.info(
1378 'connecting to juju controller: {} {}:{}{}'
1379 .format(self.url, self.username, self.secret[:8] + '...', ' with ca_cert' if self.ca_cert else ''))
1380
1381 # Create controller object
1382 self.controller = Controller(loop=self.loop)
1383 # Connect to controller
1384 await self.controller.connect(
1385 endpoint=self.url,
1386 username=self.username,
1387 password=self.secret,
1388 cacert=self.ca_cert
1389 )
1390 self._authenticated = True
1391 self.log.info('juju controller connected')
1392 except Exception as e:
1393 message = 'Exception connecting to juju: {}'.format(e)
1394 self.log.error(message)
1395 raise N2VCConnectionException(
1396 message=message,
1397 url=self.url
1398 )
1399 finally:
1400 self._connecting = False
1401
1402 async def _juju_logout(self):
1403 """Logout of the Juju controller."""
1404 if not self._authenticated:
1405 return False
1406
1407 # disconnect all models
1408 for model_name in self.juju_models:
1409 try:
1410 await self._juju_disconnect_model(model_name)
1411 except Exception as e:
1412 self.log.error('Error disconnecting model {} : {}'.format(model_name, e))
1413 # continue with next model...
1414
1415 self.log.info("Disconnecting controller")
1416 try:
1417 await self.controller.disconnect()
1418 except Exception as e:
1419 raise N2VCConnectionException(message='Error disconnecting controller: {}'.format(e), url=self.url)
1420
1421 self.controller = None
1422 self._authenticated = False
1423 self.log.info('disconnected')
1424
1425 async def _juju_disconnect_model(
1426 self,
1427 model_name: str
1428 ):
1429 self.log.debug("Disconnecting model {}".format(model_name))
1430 if model_name in self.juju_models:
1431 await self.juju_models[model_name].disconnect()
1432 self.juju_models[model_name] = None
1433 self.juju_observers[model_name] = None
1434 else:
1435 self.warning('Cannot disconnect model: {}'.format(model_name))
1436
1437 def _create_juju_public_key(self):
1438 """Recreate the Juju public key on lcm container, if needed
1439 Certain libjuju commands expect to be run from the same machine as Juju
1440 is bootstrapped to. This method will write the public key to disk in
1441 that location: ~/.local/share/juju/ssh/juju_id_rsa.pub
1442 """
1443
1444 # Make sure that we have a public key before writing to disk
1445 if self.public_key is None or len(self.public_key) == 0:
1446 if 'OSMLCM_VCA_PUBKEY' in os.environ:
1447 self.public_key = os.getenv('OSMLCM_VCA_PUBKEY', '')
1448 if len(self.public_key) == 0:
1449 return
1450 else:
1451 return
1452
1453 pk_path = "{}/.local/share/juju/ssh".format(os.path.expanduser('~'))
1454 file_path = "{}/juju_id_rsa.pub".format(pk_path)
1455 self.log.debug('writing juju public key to file:\n{}\npublic key: {}'.format(file_path, self.public_key))
1456 if not os.path.exists(pk_path):
1457 # create path and write file
1458 os.makedirs(pk_path)
1459 with open(file_path, 'w') as f:
1460 self.log.debug('Creating juju public key file: {}'.format(file_path))
1461 f.write(self.public_key)
1462 else:
1463 self.log.debug('juju public key file already exists: {}'.format(file_path))
1464
1465 @staticmethod
1466 def _format_model_name(name: str) -> str:
1467 """Format the name of the model.
1468
1469 Model names may only contain lowercase letters, digits and hyphens
1470 """
1471
1472 return name.replace('_', '-').replace(' ', '-').lower()
1473
1474 @staticmethod
1475 def _format_app_name(name: str) -> str:
1476 """Format the name of the application (in order to assure valid application name).
1477
1478 Application names have restrictions (run juju deploy --help):
1479 - contains lowercase letters 'a'-'z'
1480 - contains numbers '0'-'9'
1481 - contains hyphens '-'
1482 - starts with a lowercase letter
1483 - not two or more consecutive hyphens
1484 - after a hyphen, not a group with all numbers
1485 """
1486
1487 def all_numbers(s: str) -> bool:
1488 for c in s:
1489 if not c.isdigit():
1490 return False
1491 return True
1492
1493 new_name = name.replace('_', '-')
1494 new_name = new_name.replace(' ', '-')
1495 new_name = new_name.lower()
1496 while new_name.find('--') >= 0:
1497 new_name = new_name.replace('--', '-')
1498 groups = new_name.split('-')
1499
1500 # find 'all numbers' groups and prefix them with a letter
1501 app_name = ''
1502 for i in range(len(groups)):
1503 group = groups[i]
1504 if all_numbers(group):
1505 group = 'z' + group
1506 if i > 0:
1507 app_name += '-'
1508 app_name += group
1509
1510 if app_name[0].isdigit():
1511 app_name = 'z' + app_name
1512
1513 return app_name