gc_integration_templater/render_engine.py

390 lines
16 KiB
Python
Raw Normal View History

2023-11-20 12:34:46 +00:00
#!/usr/bin/env python
import sys
from pathlib import Path
import os
import yaml
import jinja2
2023-12-21 17:29:43 +00:00
import toml
import logging
## global logger
logger = logging.getLogger('main')
logger.setLevel(logging.INFO)
console = logging.StreamHandler()
file = logging.FileHandler('render_engine.log')
logger.addHandler(console)
logger.addHandler(file)
formatter = logging.Formatter(
fmt="%(asctime)s, %(levelname)-8s | %(filename)-15s:%(lineno)-5s | %(threadName)-1s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S")
console.setFormatter(formatter)
file.setFormatter(formatter)
## logic
class ReadConfig():
'read or generate script config file'
def __init__(self, config_file):
# dirty way to write a text block
self.example_config = """\
config_contexts_dir = './config_contexts'
platform_templates_dir = './platform_templates'
rendered_templates_dir = './rendered_templates'
inventory_file = './inventory.yml'\
"""
self.required_variables = ['config_contexts_dir', 'platform_templates_dir', 'rendered_templates_dir', 'inventory_file']
self.config_file = config_file
self.config_dict = self.__find_config(self.example_config, self.config_file)
self.__parseConfig(self.config_dict)
def __find_config(self, example_config, config_file):
if not Path(config_file).exists():
with open(config_file,'w') as config:
config.write(example_config)
logger.error(f'config missing, wrote example to {config_file} please modify and re-run')
sys.exit(0)
else:
try:
config_dict = toml.load(config_file)
except:
logger.error(f'invalid TOML format: {config_file}')
sys.exit(1)
return config_dict
def __parseConfig(self, config_dict):
# missing entries listed in self.required_variables
for i in self.required_variables:
try:
test = config_dict[i]
except:
logger.error(f'missing config entry: {i}')
sys.exit(1)
# empty entries in self.required_variables
for i in self.required_variables:
if not len(config_dict[i]):
logger.error(f'empty config entry: {i}')
sys.exit(1)
# missing files/directory in self.required_variables
missing = False
for i in self.required_variables:
if '_dir' in i:
directory = Path(config_dict[i])
if not directory.is_dir():
logger.error(f'missing directory: {i}= {config_dict[i]}')
missing = True
if '_file' in i:
file = Path(config_dict[i])
if not file.is_file():
print(f'missing file: {i} = {config_dict[i]}')
missing =True
if missing:
sys.exit(1)
def get_config(self):
return self.config_dict
def get_config_item(self, config_item):
if config_item in self.required_variables:
return self.config_dict[config_item]
2023-11-20 12:34:46 +00:00
2023-12-21 17:29:43 +00:00
class InventoryFileChecks:
2023-11-20 12:34:46 +00:00
'file check rules'
2023-12-21 17:29:43 +00:00
def __init__(self, config_dict):
self.config_contexts_dir = config_dict['config_contexts_dir']
self.templates_dir = config_dict['platform_templates_dir']
self.inventory_file = config_dict['inventory_file']
2023-11-20 12:34:46 +00:00
self.inventory_dict = self.__read_inventory_file(self.inventory_file)
self.__check_context_files(self.config_contexts_dir)
self.__check_platform_templates(self.inventory_dict, self.templates_dir)
def __read_inventory_file(self, inventory_file):
try:
with open(inventory_file, "r") as file:
return yaml.safe_load(file)
except Exception as ex:
if type(ex).__name__ == 'ScannerError':
2023-12-21 17:29:43 +00:00
logger.error(f'invalid YAML: {inventory_file}')
2023-11-20 12:34:46 +00:00
sys.exit(1)
elif type(ex).__name__ == 'FileNotFoundError':
2023-12-21 17:29:43 +00:00
logger.error(f'missing inventory file: {inventory_file}')
2023-11-20 12:34:46 +00:00
sys.exit(1)
else:
2023-12-21 17:29:43 +00:00
logger.error(f'unable to load inventory file: {inventory_file}')
2023-11-20 12:34:46 +00:00
sys.exit(1)
def __check_context_files(self, config_contexts_dir):
context_files = {}
# check for contexts directory - need an all file, dont need a device file
for path in Path(config_contexts_dir).rglob('*.yml'):
context_files.update({str(path): { 'file': path.name,'path': str(path.parent)}})
file_names = [context_files[i]['file'] for i in context_files.keys()]
duplicate_files = list(set([i for i in file_names if file_names.count(i) > 1]))
if len(duplicate_files) >0:
2023-12-21 17:29:43 +00:00
logger.error(f'to maintain readability there no logic to handle duplicate file names under the config_contexts directory')
2023-11-20 12:34:46 +00:00
for i in duplicate_files:
for j in context_files.keys():
if context_files[j]['file'] == i:
2023-12-21 17:29:43 +00:00
logger.error(f"duplicate file name {context_files[j]['file']} @ {j}")
2023-11-20 12:34:46 +00:00
sys.exit(1)
def __check_platform_templates(self, inventory_dict, templates_dir):
platforms = set([inventory_dict[i]['platform'] for i in inventory_dict.keys()])
for i in platforms:
template = Path(f'{templates_dir}/{i}.j2')
if not template.is_file():
target_device_list = [k for k, v in inventory_dict.items() if v['platform'] == i]
target_devices = '\n'.join(target_device_list)
2023-12-21 17:29:43 +00:00
logger.error(f'missing template {template}, nothing will be rendered for devices:\n{target_devices}')
2023-11-20 12:34:46 +00:00
2023-12-21 17:29:43 +00:00
class LoadInventory(InventoryFileChecks):
2023-11-20 12:34:46 +00:00
'load inventory'
2023-12-21 17:29:43 +00:00
def __init__(self, config_dict):
self.config_dict = config_dict
InventoryFileChecks.__init__(self, self.config_dict)
2023-11-20 12:34:46 +00:00
self.all_config_contexts, self.devices = self.__load_contexts(self.inventory_dict)
2023-12-21 17:29:43 +00:00
## debug
# print(yaml.dump(self.all_config_contexts))
2023-11-20 12:34:46 +00:00
def __remove_meta(self, data):
try:
del data['_metadata']
except KeyError:
pass
return data
def __load_contexts(self, inventory_dict):
# get path of all device and context files
devices_path = set()
contexts_path = set()
devices = []
for i in inventory_dict.keys():
devices_path.add(f'{self.config_contexts_dir}/device/{i}.yml')
devices.append(i)
for k, v in inventory_dict[i]['config_contexts'].items():
if isinstance(v, list):
for i in v:
contexts_path.add(f'{self.config_contexts_dir}/{k}/{i}.yml')
else:
contexts_path.add(f'{self.config_contexts_dir}/{k}/{v}.yml')
2023-12-21 17:29:43 +00:00
## debug
2023-11-20 12:34:46 +00:00
# print(devices_path)
# print(contexts_path)
# load config contexts
all_config_contexts = {}
for i in contexts_path:
try:
with open(i, 'r') as file:
data = yaml.safe_load(file)
if data['_metadata']['weight'] and data['_metadata']['is_active']:
# build the context_name from the file parent directory
# context naming could be sourced from the metadata dict but the potential for duplicate filenames under config_contexts subdirectories is to be discouraged
context = os.path.splitext(os.path.basename(i))[0]
all_config_contexts.update({context: {'weight': data['_metadata']['weight'], 'content': self.__remove_meta(data)}})
except:
pass
2023-12-21 17:29:43 +00:00
## debug
2023-11-20 12:34:46 +00:00
# print(all_config_contexts)
# load device contexts
for i in devices_path:
try:
with open(i, 'r') as file:
data = yaml.safe_load(file)
context = os.path.splitext(os.path.basename(i))[0]
all_config_contexts.update({context: {'weight': 0, 'content': self.__remove_meta(data)}})
except:
pass
# load all contexts
all_weight = sorted([all_config_contexts[k]['weight'] for k, v in all_config_contexts.items()], reverse=True)
max_weight = all_weight[0]+1
try:
with open(f'{self.config_contexts_dir}/all.yml', 'r') as file:
data = yaml.safe_load(file)
if data['_metadata']['is_active']:
all_config_contexts.update({'all': {'weight': max_weight, 'content': self.__remove_meta(data)}})
except:
pass
return all_config_contexts, devices
def get_contexts(self):
return self.all_config_contexts
def get_devices(self):
return self.devices
def get_device_platform(self, device):
return self.inventory_dict[device]['platform']
def get_device_template(self, device):
platform = self.get_device_platform(device)
return f'{self.templates_dir}/{platform}.j2'
def get_device_contexts(self, device):
device_contexts = {}
all_device_contexts = ['all', device]
for i in all_device_contexts:
if i in self.all_config_contexts:
device_contexts.update({i: self.all_config_contexts[i]})
for k, v in self.inventory_dict[device]['config_contexts'].items():
if isinstance(v, list):
for i in v:
if i in self.all_config_contexts:
device_contexts.update({i: self.all_config_contexts[i]})
elif v in self.all_config_contexts:
device_contexts.update({v: self.all_config_contexts[v]})
return device_contexts
2023-12-21 17:29:43 +00:00
def get_rendered_templates_path(self):
return self.config_dict['rendered_templates_dir']
2023-11-20 12:34:46 +00:00
class BuildDeviceContext:
'build config_context for a device'
def __init__(self, LoadInventory_instance, device):
self.device = device
self.contexts = LoadInventory_instance.get_device_contexts(self.device)
self.platform = LoadInventory_instance.get_device_platform(self.device)
2023-12-21 17:29:43 +00:00
## debug
2023-11-20 12:34:46 +00:00
# print(self.contexts)
# print(self.platform)
self.config_context = self.__config_context(self.device, self.contexts)
def __config_context(self, device, contexts):
# check for equally weighted clashing keys
all_weight = sorted([contexts[k]['weight'] for k, v in contexts.items()], reverse=True)
dupe_weight = list(set([x for x in all_weight if all_weight.count(x) > 1]))
equal_weight = {}
for i in dupe_weight:
equal_weight.update({i: []})
for k in contexts.keys():
if contexts[k]['weight'] == i:
equal_weight[i].append(k)
2023-12-21 17:29:43 +00:00
## debug
2023-11-20 12:34:46 +00:00
# print(f'equal weight contexts: {equal_weight}')
# list all keys in equally weighted contexts
keys_list = []
for k in equal_weight.keys():
for i in equal_weight[k]:
for k in contexts[i]['content'].keys():
keys_list.append(k)
keys_list = list(set(keys_list))
2023-12-21 17:29:43 +00:00
## debug
2023-11-20 12:34:46 +00:00
# print(f'all keys in equal weight contexts: {keys_list}')
# check each equally weighted context for clashing keys
equal_weight_key_clash_errors = []
for w in equal_weight:
for k in keys_list:
key_occurance = []
for i in equal_weight[w]:
if k in contexts[i]['content']:
key_occurance.append(i)
if len(key_occurance) >1:
equal_weight_key_clash_errors.append(f'clashing key found in equally weighted contexts: \nkey: {k} \ncontexts: {key_occurance}')
if len(equal_weight_key_clash_errors) >0:
2023-12-21 17:29:43 +00:00
error_log = f'device config_context render issue: {device}'
2023-11-20 12:34:46 +00:00
for i in equal_weight_key_clash_errors:
error_log = error_log + f'\n{i}\n'
2023-12-21 17:29:43 +00:00
logger.error(error_log)
2023-11-20 12:34:46 +00:00
return {}
# reorder all_config_contexts by weight, update the config_context with each context in order of precedence, overwriting top level keys where they exist (the last context being the lowest weight and highest precedence)
config_context = {}
contexts = {k: v for k, v in sorted(contexts.items(), key=lambda x: x[1]['weight'], reverse=True)}
for k, v in contexts.items():
config_context = {**config_context, **v['content']}
config_context = dict(sorted(config_context.items()))
2023-12-21 17:29:43 +00:00
## debug
2023-11-20 12:34:46 +00:00
# print(yaml.dump(config_context))
return config_context
def get_config_context(self):
return self.config_context
def get_device_name(self):
return self.device
class RenderDeviceConfig(BuildDeviceContext):
'build config_context for a device'
def __init__(self, LoadInventory_instance, device):
BuildDeviceContext.__init__(self, LoadInventory_instance, device)
self.device_template = LoadInventory_instance.get_device_template(self.device)
2023-12-21 17:29:43 +00:00
self.rendered_template_path = LoadInventory_instance.get_rendered_templates_path()
2023-11-20 12:34:46 +00:00
self.rendered_template = self.__rendered_template(self.device, self.config_context, self.device_template)
def __rendered_template(self, device, config_context, device_template):
if len(config_context) >0:
template_directory = os.path.dirname(device_template)
template_file = os.path.basename(device_template)
environment = jinja2.Environment(loader=jinja2.FileSystemLoader(template_directory))
environment.trim_blocks = True
environment.lstrip_blocks = True
# add config_context dict to a global in the rendering environment, access variables like golden config
environment.globals['config_context'] = config_context
# pretty_config_context is for illustration only, non functional variable as keys cannot be accessed
environment.globals['pretty_config_context'] = yaml.dump(config_context, default_flow_style=0)
template = environment.get_template(template_file)
# render without specifying variables or a dict as input, instead use environment.globals['config_context']
rendered_template_content = template.render()
# rendered_template_content = template.render(config_context)
return rendered_template_content
else:
2023-12-21 17:29:43 +00:00
logger.error(f'device template render issue: {device}\ntemplate: {os.path.dirname(device_template)}/{os.path.basename(device_template)}\nempty config_context: {config_context}')
2023-11-20 12:34:46 +00:00
return
def print_rendered_template(self):
if self.rendered_template is not None:
2023-12-21 17:29:43 +00:00
logger.info(f'{self.device} rendered file content:\n\n{self.rendered_template}\n')
2023-11-20 12:34:46 +00:00
def write_rendered_template(self):
if self.rendered_template is not None:
2023-12-21 17:29:43 +00:00
rendered_template_file = f'{self.rendered_template_path}/{self.device}.txt'
2023-11-20 12:34:46 +00:00
with open(rendered_template_file, mode="w", encoding="utf-8") as message:
message.write(self.rendered_template)
2023-12-21 17:29:43 +00:00
logger.info(f'{self.device} rendered file written: {rendered_template_file}')
2023-11-20 12:34:46 +00:00
2023-12-21 17:29:43 +00:00
## start
2023-11-20 12:34:46 +00:00
def main():
2023-12-21 17:29:43 +00:00
# config validation
config_file = 'config.toml'
config_obj = ReadConfig(config_file)
# load inventory
inventory = LoadInventory(config_obj.get_config())
## debug
2023-11-20 12:34:46 +00:00
# print(inventory.get_devices())
# print(yaml.dump(inventory.get_contexts()))
2023-12-21 17:29:43 +00:00
# render templates
2023-11-20 12:34:46 +00:00
config_contexts_list = []
for i in inventory.get_devices():
context_obj = RenderDeviceConfig(inventory, i)
if len(context_obj.get_config_context()) >0:
2023-12-21 17:29:43 +00:00
## debug
2023-11-20 12:34:46 +00:00
# print(f'device:\n{context_obj.get_device_name()}\n')
# print(f'config_context:\n{yaml.dump(context_obj.get_config_context())}')
config_contexts_list.append(context_obj)
else:
del context_obj
2023-12-21 17:29:43 +00:00
# write rendered configs
2023-11-20 12:34:46 +00:00
for i in config_contexts_list:
i.write_rendered_template()
2023-12-21 17:29:43 +00:00
## debug
# i.print_rendered_template()
2023-11-20 12:34:46 +00:00
if __name__ == "__main__":
main()
2023-12-21 17:29:43 +00:00
# parameters for:
# - non standard config file location
# - show me all devices in inventory
# - target specific device for view or render
# - view/render
# want a comment block or maybe for now just add a date/time in the file name - do this for now - this is going to be a git commit thing is there any point?