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: \n key: { k } \n contexts: { 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 } \n template: { os . path . dirname ( device_template ) } / { os . path . basename ( device_template ) } \n empty 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?