fix(juju): honor juju changes in descriptor
[osm/osmclient.git] / osmclient / common / package_tool.py
1 # /bin/env python3
2 # Copyright 2019 ATOS
3 #
4 # All Rights Reserved.
5 #
6 # Licensed under the Apache License, Version 2.0 (the "License"); you may
7 # not use this file except in compliance with the License. You may obtain
8 # 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, WITHOUT
14 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15 # License for the specific language governing permissions and limitations
16 # under the License.
17
18 import glob
19 import hashlib
20 import logging
21 import os
22 import shutil
23 import subprocess
24 import tarfile
25 import time
26
27 from jinja2 import Environment, PackageLoader
28 from osm_im.validation import Validation as validation_im
29 from osm_im.validation import ValidationException
30 from osm_im import im_translation
31 from osmclient.common.exceptions import ClientException
32 import yaml
33
34
35 class PackageTool(object):
36 def __init__(self, client=None):
37 self._client = client
38 self._logger = logging.getLogger('osmclient')
39 self._validator = validation_im()
40
41 def create(self, package_type, base_directory, package_name, override, image, vdus, vcpu, memory, storage,
42 interfaces, vendor, detailed, netslice_subnets, netslice_vlds):
43 """
44 **Create a package descriptor**
45
46 :params:
47 - package_type: [vnf, ns, nst]
48 - base directory: path of destination folder
49 - package_name: is the name of the package to be created
50 - image: specify the image of the vdu
51 - vcpu: number of virtual cpus of the vdu
52 - memory: amount of memory in MB pf the vdu
53 - storage: amount of storage in GB of the vdu
54 - interfaces: number of interfaces besides management interface
55 - vendor: vendor name of the vnf/ns
56 - detailed: include all possible values for NSD, VNFD, NST
57 - netslice_subnets: number of netslice_subnets for the NST
58 - netslice_vlds: number of virtual link descriptors for the NST
59
60 :return: status
61 """
62 self._logger.debug("")
63 # print("location: {}".format(osmclient.__path__))
64 file_loader = PackageLoader("osmclient")
65 env = Environment(loader=file_loader)
66 if package_type == 'ns':
67 template = env.get_template('nsd.yaml.j2')
68 content = {"name": package_name, "vendor": vendor, "vdus": vdus, "clean": False, "interfaces": interfaces,
69 "detailed": detailed}
70 elif package_type == 'vnf':
71 template = env.get_template('vnfd.yaml.j2')
72 content = {"name": package_name, "vendor": vendor, "vdus": vdus, "clean": False, "interfaces": interfaces,
73 "image": image, "vcpu": vcpu, "memory": memory, "storage": storage, "detailed": detailed}
74 elif package_type == 'nst':
75 template = env.get_template('nst.yaml.j2')
76 content = {"name": package_name, "vendor": vendor, "interfaces": interfaces,
77 "netslice_subnets": netslice_subnets, "netslice_vlds": netslice_vlds, "detailed": detailed}
78 else:
79 raise ClientException("Wrong descriptor type {}. Options: ns, vnf, nst".format(package_type))
80
81 # print("To be rendered: {}".format(content))
82 output = template.render(content)
83 # print(output)
84
85 structure = self.discover_folder_structure(base_directory, package_name, override)
86 if structure.get("folders"):
87 self.create_folders(structure["folders"], package_type)
88 if structure.get("files"):
89 self.create_files(structure["files"], output, package_type)
90 return "Created"
91
92 def validate(self, base_directory, recursive=True, old_format=False):
93 """
94 **Validate OSM Descriptors given a path**
95
96 :params:
97 - base_directory is the root path for all descriptors
98
99 :return: List of dict of validated descriptors. keys: type, path, valid, error
100 """
101 self._logger.debug("")
102 table = []
103 if recursive:
104 descriptors_paths = [f for f in glob.glob(base_directory + "/**/*.yaml", recursive=recursive)]
105 else:
106 descriptors_paths = [f for f in glob.glob(base_directory + "/*.yaml", recursive=recursive)]
107 print("Base directory: {}".format(base_directory))
108 print("{} Descriptors found to validate".format(len(descriptors_paths)))
109 for desc_path in descriptors_paths:
110 with open(desc_path) as descriptor_file:
111 descriptor_data = descriptor_file.read()
112 desc_type = "-"
113 try:
114 desc_type, descriptor_data = validation_im.yaml_validation(self, descriptor_data)
115 if not old_format:
116 if ( desc_type=="vnfd" or desc_type=="nsd" ):
117 print("OSM descriptor '{}' written in an unsupported format. Please update to ETSI SOL006 format".format(desc_path))
118 print("Package validation skipped. It can still be done with 'osm package-validate --old'")
119 print("Package build can still be done with 'osm package-build --skip-validation'")
120 raise Exception("Not SOL006 format")
121 validation_im.pyangbind_validation(self, desc_type, descriptor_data)
122 table.append({"type": desc_type, "path": desc_path, "valid": "OK", "error": "-"})
123 except Exception as e:
124 table.append({"type": desc_type, "path": desc_path, "valid": "ERROR", "error": str(e)})
125 return table
126
127 def translate(self, base_directory, recursive=True, dryrun=False):
128 """
129 **Translate OSM Packages given a path**
130
131 :params:
132 - base_directory is the root path for all packages
133
134 :return: List of dict of translated packages. keys: current type, new type, path, valid, translated, error
135 """
136 self._logger.debug("")
137 table = []
138 if recursive:
139 descriptors_paths = [f for f in glob.glob(base_directory + "/**/*.yaml", recursive=recursive)]
140 else:
141 descriptors_paths = [f for f in glob.glob(base_directory + "/*.yaml", recursive=recursive)]
142 print("Base directory: {}".format(base_directory))
143 print("{} Descriptors found to validate".format(len(descriptors_paths)))
144 for desc_path in descriptors_paths:
145 with open(desc_path) as descriptor_file:
146 descriptor_data = descriptor_file.read()
147 desc_type = "-"
148 try:
149 desc_type, descriptor_data = validation_im.yaml_validation(self, descriptor_data)
150 self._logger.debug("desc_type: {}".format(desc_type))
151 self._logger.debug("descriptor_data:\n{}".format(descriptor_data))
152 self._validator.pyangbind_validation(desc_type, descriptor_data)
153 if not ( desc_type=="vnfd" or desc_type=="nsd" ):
154 table.append({"current type": desc_type, "new type": desc_type, "path": desc_path, "valid": "OK", "translated": "N/A", "error": "-"})
155 else:
156 new_desc_type = desc_type
157 try:
158 sol006_model = yaml.safe_dump(im_translation.translate_im_model_to_sol006(descriptor_data), indent=4, default_flow_style=False)
159 new_desc_type, new_descriptor_data = self._validator.yaml_validation(sol006_model)
160 self._validator.pyangbind_validation(new_desc_type, new_descriptor_data)
161 if not dryrun:
162 with open(desc_path, 'w') as descriptor_file:
163 descriptor_file.write(sol006_model)
164 table.append({"current type": desc_type, "new type": new_desc_type, "path": desc_path, "valid": "OK", "translated": "OK", "error": "-"})
165 except ValidationException as ve2:
166 table.append({"current type": desc_type, "new type": new_desc_type, "path": desc_path, "valid": "OK", "translated": "ERROR", "error": "Error in the post-validation: {}".format(str(ve2))})
167 except Exception as e2:
168 table.append({"current type": desc_type, "new type": new_desc_type, "path": desc_path, "valid": "OK", "translated": "ERROR", "error": "Error in the translation: {}".format(str(e2))})
169 except ValidationException as ve:
170 table.append({"current type": desc_type, "new type": "N/A", "path": desc_path, "valid": "ERROR", "translated": "N/A", "error": "Error in the pre-validation: {}".format(str(ve))})
171 except Exception as e:
172 table.append({"current type": desc_type, "new type": "N/A", "path": desc_path, "valid": "ERROR", "translated": "N/A", "error": str(e)})
173 return table
174
175 def descriptor_translate(self, descriptor_file):
176 """
177 **Translate input descriptor file from Rel EIGHT OSM to SOL006**
178
179 :params:
180 - base_directory is the root path for all packages
181
182 :return: YAML descriptor in the new format
183 """
184 self._logger.debug("")
185 with open(descriptor_file, 'r') as df:
186 im_model = yaml.safe_load(df.read())
187 sol006_model = im_translation.translate_im_model_to_sol006(im_model)
188 return yaml.safe_dump(sol006_model, indent=4, default_flow_style=False)
189
190 def build(self, package_folder, skip_validation=False, skip_charm_build=False):
191 """
192 **Creates a .tar.gz file given a package_folder**
193
194 :params:
195 - package_folder: is the name of the folder to be packaged
196 - skip_validation: is the flag to validate or not the descriptors on the folder before build
197
198 :returns: message result for the build process
199 """
200 self._logger.debug("")
201 package_folder = package_folder.rstrip('/')
202 if not os.path.exists("{}".format(package_folder)):
203 return "Fail, package is not in the specified path"
204 if not skip_validation:
205 print('Validating package {}'.format(package_folder))
206 results = self.validate(package_folder, recursive=False)
207 if results:
208 for result in results:
209 if result["valid"] != "OK":
210 raise ClientException("There was an error validating the file {} with error: {}"
211 .format(result["path"], result["error"]))
212 print('Validation OK')
213 else:
214 raise ClientException("No descriptor file found in: {}".format(package_folder))
215 charm_list = self.build_all_charms(package_folder, skip_charm_build)
216 return self.build_tarfile(package_folder, charm_list)
217
218 def calculate_checksum(self, package_folder):
219 """
220 **Function to calculate the checksum given a folder**
221
222 :params:
223 - package_folder: is the folder where we have the files to calculate the checksum
224 :returns: None
225 """
226 self._logger.debug("")
227 files = [f for f in glob.glob(package_folder + "/**/*.*", recursive=True) if os.path.isfile(f)]
228 with open("{}/checksums.txt".format(package_folder), "w+") as checksum:
229 for file_item in files:
230 if "checksums.txt" in file_item:
231 continue
232 # from https://www.quickprogrammingtips.com/python/how-to-calculate-md5-hash-of-a-file-in-python.html
233 md5_hash = hashlib.md5()
234 with open(file_item, "rb") as f:
235 # Read and update hash in chunks of 4K
236 for byte_block in iter(lambda: f.read(4096), b""):
237 md5_hash.update(byte_block)
238 checksum.write("{}\t{}\n".format(md5_hash.hexdigest(), file_item))
239
240 def create_folders(self, folders, package_type):
241 """
242 **Create folder given a list of folders**
243
244 :params:
245 - folders: [List] list of folders paths to be created
246 - package_type: is the type of package to be created
247 :return: None
248 """
249 self._logger.debug("")
250 for folder in folders:
251 try:
252 # print("Folder {} == package_type {}".format(folder[1], package_type))
253 if folder[1] == package_type:
254 print("Creating folder:\t{}".format(folder[0]))
255 os.makedirs(folder[0])
256 except FileExistsError:
257 pass
258
259 def save_file(self, file_name, file_body):
260 """
261 **Create a file given a name and the content**
262
263 :params:
264 - file_name: is the name of the file with the relative route
265 - file_body: is the content of the file
266 :return: None
267 """
268 self._logger.debug("")
269 print("Creating file: \t{}".format(file_name))
270 try:
271 with open(file_name, "w+") as f:
272 f.write(file_body)
273 except Exception as e:
274 raise ClientException(e)
275
276 def generate_readme(self):
277 """
278 **Creates the README content**
279
280 :returns: readme content
281 """
282 self._logger.debug("")
283 return """# Descriptor created by OSM descriptor package generated\n\n**Created on {} **""".format(
284 time.strftime("%m/%d/%Y, %H:%M:%S", time.localtime()))
285
286 def generate_cloud_init(self):
287 """
288 **Creates the cloud-init content**
289
290 :returns: cloud-init content
291 """
292 self._logger.debug("")
293 return "---\n#cloud-config"
294
295 def create_files(self, files, file_content, package_type):
296 """
297 **Creates the files given the file list and type**
298
299 :params:
300 - files: is the list of files structure
301 - file_content: is the content of the descriptor rendered by the template
302 - package_type: is the type of package to filter the creation structure
303
304 :return: None
305 """
306 self._logger.debug("")
307 for file_item, file_package, file_type in files:
308 if package_type == file_package:
309 if file_type == "descriptor":
310 self.save_file(file_item, file_content)
311 elif file_type == "readme":
312 self.save_file(file_item, self.generate_readme())
313 elif file_type == "cloud_init":
314 self.save_file(file_item, self.generate_cloud_init())
315
316 def check_files_folders(self, path_list, override):
317 """
318 **Find files and folders missing given a directory structure {"folders": [], "files": []}**
319
320 :params:
321 - path_list: is the list of files and folders to be created
322 - override: is the flag used to indicate the creation of the list even if the file exist to override it
323
324 :return: Missing paths Dict
325 """
326 self._logger.debug("")
327 missing_paths = {}
328 folders = []
329 files = []
330 for folder in path_list.get("folders"):
331 if not os.path.exists(folder[0]):
332 folders.append(folder)
333 missing_paths["folders"] = folders
334
335 for file_item in path_list.get("files"):
336 if not os.path.exists(file_item[0]) or override is True:
337 files.append(file_item)
338 missing_paths["files"] = files
339
340 return missing_paths
341
342 def build_all_charms(self, package_folder, skip_charm_build):
343 """
344 **Read the descriptor file, check that the charms referenced are in the folder and compiles them**
345
346 :params:
347 - packet_folder: is the location of the package
348 :return: Files and Folders not found. In case of override, it will return all file list
349 """
350 self._logger.debug("")
351 listCharms = []
352 descriptor_file = False
353 descriptors_paths = [f for f in glob.glob(package_folder + "/*.yaml")]
354 for file in descriptors_paths:
355 if file.endswith('nfd.yaml'):
356 descriptor_file = True
357 listCharms = self.charms_search(file, 'vnf')
358 if file.endswith('nsd.yaml'):
359 descriptor_file = True
360 listCharms = self.charms_search(file, 'ns')
361 print("List of charms in the descriptor: {}".format(listCharms))
362 if not descriptor_file:
363 raise ClientException('Descriptor filename is not correct in: {}. It should end with "nfd.yaml" or "nsd.yaml"'.format(package_folder))
364 if listCharms and not skip_charm_build:
365 for charmName in listCharms:
366 if os.path.isdir('{}/charms/layers/{}'.format(package_folder, charmName)):
367 print('Building charm {}/charms/layers/{}'.format(package_folder, charmName))
368 self.charm_build(package_folder, charmName)
369 print('Charm built: {}'.format(charmName))
370 else:
371 if not os.path.isdir('{}/charms/{}'.format(package_folder, charmName)):
372 raise ClientException('The charm: {} referenced in the descriptor file '
373 'is not present either in {}/charms or in {}/charms/layers'.
374 format(charmName, package_folder, package_folder))
375 self._logger.debug("Return list of charms: {}".format(listCharms))
376 return listCharms
377
378 def discover_folder_structure(self, base_directory, name, override):
379 """
380 **Discover files and folders structure for OSM descriptors given a base_directory and name**
381
382 :params:
383 - base_directory: is the location of the package to be created
384 - name: is the name of the package
385 - override: is the flag used to indicate the creation of the list even if the file exist to override it
386 :return: Files and Folders not found. In case of override, it will return all file list
387 """
388 self._logger.debug("")
389 prefix = "{}/{}".format(base_directory, name)
390 files_folders = {"folders": [("{}_ns".format(prefix), "ns"),
391 ("{}_ns/icons".format(prefix), "ns"),
392 ("{}_ns/charms".format(prefix), "ns"),
393 ("{}_vnf".format(name), "vnf"),
394 ("{}_vnf/charms".format(prefix), "vnf"),
395 ("{}_vnf/cloud_init".format(prefix), "vnf"),
396 ("{}_vnf/images".format(prefix), "vnf"),
397 ("{}_vnf/icons".format(prefix), "vnf"),
398 ("{}_vnf/scripts".format(prefix), "vnf"),
399 ("{}_nst".format(prefix), "nst"),
400 ("{}_nst/icons".format(prefix), "nst")
401 ],
402 "files": [("{}_ns/{}_nsd.yaml".format(prefix, name), "ns", "descriptor"),
403 ("{}_ns/README.md".format(prefix), "ns", "readme"),
404 ("{}_vnf/{}_vnfd.yaml".format(prefix, name), "vnf", "descriptor"),
405 ("{}_vnf/cloud_init/cloud-config.txt".format(prefix), "vnf", "cloud_init"),
406 ("{}_vnf/README.md".format(prefix), "vnf", "readme"),
407 ("{}_nst/{}_nst.yaml".format(prefix, name), "nst", "descriptor"),
408 ("{}_nst/README.md".format(prefix), "nst", "readme")
409 ]
410 }
411 missing_files_folders = self.check_files_folders(files_folders, override)
412 # print("Missing files and folders: {}".format(missing_files_folders))
413 return missing_files_folders
414
415 def charm_build(self, charms_folder, build_name):
416 """
417 Build the charms inside the package.
418 params: package_folder is the name of the folder where is the charms to compile.
419 build_name is the name of the layer or interface
420 """
421 self._logger.debug("")
422 os.environ['JUJU_REPOSITORY'] = "{}/charms".format(charms_folder)
423 os.environ['CHARM_LAYERS_DIR'] = "{}/layers".format(os.environ['JUJU_REPOSITORY'])
424 os.environ['CHARM_INTERFACES_DIR'] = "{}/interfaces".format(os.environ['JUJU_REPOSITORY'])
425 os.environ['CHARM_BUILD_DIR'] = "{}/charms/builds".format(charms_folder)
426 if not os.path.exists(os.environ['CHARM_BUILD_DIR']):
427 os.makedirs(os.environ['CHARM_BUILD_DIR'])
428 src_folder = '{}/{}'.format(os.environ['CHARM_LAYERS_DIR'], build_name)
429 result = subprocess.run(["charm", "build", "{}".format(src_folder)])
430 if result.returncode == 1:
431 raise ClientException("failed to build the charm: {}".format(src_folder))
432 self._logger.verbose("charm {} built".format(src_folder))
433
434 def build_tarfile(self, package_folder, charm_list=None):
435 """
436 Creates a .tar.gz file given a package_folder
437 params: package_folder is the name of the folder to be packaged
438 returns: .tar.gz name
439 """
440 self._logger.debug("")
441 cwd = None
442 try:
443 directory_name, package_name = self.create_temp_dir(package_folder, charm_list)
444 cwd = os.getcwd()
445 os.chdir(directory_name)
446 self.calculate_checksum(package_name)
447 with tarfile.open("{}.tar.gz".format(package_name), mode='w:gz') as archive:
448 print("Adding File: {}".format(package_name))
449 archive.add('{}'.format(package_name), recursive=True)
450 # return "Created {}.tar.gz".format(package_folder)
451 # self.build("{}".format(os.path.basename(package_folder)))
452 os.chdir(cwd)
453 cwd = None
454 created_package = "{}/{}.tar.gz".format(os.path.dirname(package_folder) or '.', package_name)
455 os.rename("{}/{}.tar.gz".format(directory_name, package_name),
456 created_package)
457 os.rename("{}/{}/checksums.txt".format(directory_name, package_name),
458 "{}/checksums.txt".format(package_folder))
459 print("Package created: {}".format(created_package))
460 return created_package
461 except Exception as exc:
462 raise ClientException('failure during build of targz file (create temp dir, calculate checksum, '
463 'tar.gz file): {}'.format(exc))
464 finally:
465 if cwd:
466 os.chdir(cwd)
467 shutil.rmtree(os.path.join(package_folder, "tmp"))
468
469 def create_temp_dir(self, package_folder, charm_list=None):
470 """
471 Method to create a temporary folder where we can move the files in package_folder
472 """
473 self._logger.debug("")
474 ignore_patterns = ('.gitignore')
475 ignore = shutil.ignore_patterns(ignore_patterns)
476 directory_name = os.path.abspath(package_folder)
477 package_name = os.path.basename(directory_name)
478 directory_name += "/tmp"
479 os.makedirs("{}/{}".format(directory_name, package_name), exist_ok=True)
480 self._logger.debug("Makedirs DONE: {}/{}".format(directory_name, package_name))
481 for item in os.listdir(package_folder):
482 self._logger.debug("Item: {}".format(item))
483 if item != "tmp":
484 s = os.path.join(package_folder, item)
485 d = os.path.join(os.path.join(directory_name, package_name), item)
486 if os.path.isdir(s):
487 if item == "charms":
488 os.makedirs(d, exist_ok=True)
489 s_builds = os.path.join(s, "builds")
490 for charm in charm_list:
491 self._logger.debug("Copying charm {}".format(charm))
492 if charm in os.listdir(s):
493 s_charm = os.path.join(s, charm)
494 elif charm in os.listdir(s_builds):
495 s_charm = os.path.join(s_builds, charm)
496 else:
497 raise ClientException('The charm {} referenced in the descriptor file '
498 'could not be found in {}/charms or in {}/charms/builds'.
499 format(charm, package_folder, package_folder))
500 d_temp = os.path.join(d, charm)
501 self._logger.debug("Copying tree: {} -> {}".format(s_charm, d_temp))
502 shutil.copytree(s_charm, d_temp, symlinks=True, ignore=ignore)
503 self._logger.debug("DONE")
504 else:
505 self._logger.debug("Copying tree: {} -> {}".format(s, d))
506 shutil.copytree(s, d, symlinks=True, ignore=ignore)
507 self._logger.debug("DONE")
508 else:
509 if item in ignore_patterns:
510 continue
511 self._logger.debug("Copying file: {} -> {}".format(s, d))
512 shutil.copy2(s, d)
513 self._logger.debug("DONE")
514 return directory_name, package_name
515
516 def charms_search(self, descriptor_file, desc_type):
517 self._logger.debug("descriptor_file: {}, desc_type: {}".format(descriptor_file,
518 desc_type))
519 with open("{}".format(descriptor_file)) as yaml_desc:
520 descriptor_dict = yaml.safe_load(yaml_desc)
521 #self._logger.debug("\n"+yaml.safe_dump(descriptor_dict, indent=4, default_flow_style=False))
522
523 if ( (desc_type=="vnf" and ("vnfd:vnfd-catalog" in descriptor_dict or "vnfd-catalog" in descriptor_dict)) or
524 (desc_type=="ns" and ( "nsd:nsd-catalog" in descriptor_dict or "nsd-catalog" in descriptor_dict)) ):
525 charms_list = self._charms_search_on_osm_im_dict(descriptor_dict, desc_type)
526 else:
527 if desc_type == "ns":
528 get_charm_list = self._charms_search_on_nsd_sol006_dict
529 elif desc_type == "vnf":
530 get_charm_list = self._charms_search_on_vnfd_sol006_dict
531 else:
532 raise Exception("Bad descriptor type")
533 charms_list = get_charm_list(descriptor_dict)
534 return charms_list
535
536 def _charms_search_on_osm_im_dict(self, osm_im_dict, desc_type):
537 self._logger.debug("")
538 charms_list = []
539 for k1, v1 in osm_im_dict.items():
540 for k2, v2 in v1.items():
541 for entry in v2:
542 if '{}-configuration'.format(desc_type) in entry:
543 vnf_config = entry['{}-configuration'.format(desc_type)]
544 for k3, v3 in vnf_config.items():
545 if 'charm' in v3:
546 charms_list.append((v3['charm']))
547 if 'vdu' in entry:
548 vdus = entry['vdu']
549 for vdu in vdus:
550 if 'vdu-configuration' in vdu:
551 for k4, v4 in vdu['vdu-configuration'].items():
552 if 'charm' in v4:
553 charms_list.append((v4['charm']))
554 return charms_list
555
556 def _charms_search_on_vnfd_sol006_dict(self, sol006_dict):
557 self._logger.debug("")
558 charms_list = []
559 for k1, v1 in sol006_dict.items():
560 for k2, v2 in v1.items():
561 if k2 == "df":
562 for df in v2:
563 lcm_ops = df.get("lcm-operations-configuration", {})
564 ops_config = lcm_ops.get("operate-vnf-op-config", {})
565 for day_12_config in ops_config.get("day1-2", []):
566 self._logger.debug("Execution environment found")
567 for ee in day_12_config.get("execution-environment-list", []):
568 if "juju" in ee:
569 charms_list.append((ee["juju"]['charm']))
570 return charms_list
571
572 def _charms_search_on_nsd_sol006_dict(self, sol006_dict):
573 self._logger.debug("")
574 charms_list = []
575 nsd_list = sol006_dict.get("nsd", {}).get("nsd", [])
576 for nsd in nsd_list:
577 charm = nsd.get("ns-configuration", {}).get("juju", {}).get("charm")
578 if charm:
579 charms_list.append(charm)
580 return charms_list