|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import argparse |
| 4 | +from termcolor import colored, cprint |
| 5 | +from io import StringIO |
| 6 | +import json |
| 7 | +import os |
| 8 | +import re |
| 9 | +import sqlalchemy as db |
| 10 | +from sqlalchemy.sql import select |
| 11 | +import sys |
| 12 | + |
| 13 | +def convert_to_db(entity_name, make_singular=True): |
| 14 | + sql_name = entity_name.replace('-', '_').lower() |
| 15 | + if not make_singular: |
| 16 | + return sql_name |
| 17 | + |
| 18 | + if not re.search(r'ddns$', sql_name) and not re.search(r'times$', sql_name): |
| 19 | + replacements = [ |
| 20 | + [r'classes', 'class'], |
| 21 | + [r'ies', 'y'], |
| 22 | + [r's$', ''] |
| 23 | + ] |
| 24 | + |
| 25 | + for r in replacements: |
| 26 | + new_name = re.sub(r[0], r[1], sql_name) |
| 27 | + if new_name != sql_name: |
| 28 | + break |
| 29 | + sql_name = new_name |
| 30 | + |
| 31 | + return sql_name |
| 32 | + |
| 33 | +class State: |
| 34 | + def __init__(self, config, database, path = None, token_name = None): |
| 35 | + self.config = config |
| 36 | + self.database = database |
| 37 | + if path is not None: |
| 38 | + self._path = path |
| 39 | + else: |
| 40 | + self._path = [] |
| 41 | + |
| 42 | + if token_name is not None: |
| 43 | + self._path += [token_name] |
| 44 | + |
| 45 | + def copy(self, token_name = None): |
| 46 | + return State(self.config, self.database, self._path.copy(), token_name) |
| 47 | + |
| 48 | + def sql_prefix(self): |
| 49 | + return self._path[0].lower() |
| 50 | + |
| 51 | + def sql_parent_name(self): |
| 52 | + return convert_to_db(self.get_parent_name()) |
| 53 | + |
| 54 | + def sql_current_name(self): |
| 55 | + return convert_to_db(self.get_current_name(), False) |
| 56 | + |
| 57 | + def sql_current_global_name(self): |
| 58 | + return self.sql_parent_name() + '_' + self.sql_current_name() |
| 59 | + |
| 60 | + def sql_global_table_name(self): |
| 61 | + return self.sql_parent_name() + '_global_parameter' |
| 62 | + |
| 63 | + def sql_parent_table_name(self): |
| 64 | + return self.config.get_mapped_table_name('{0}_{1}'.format(self.sql_prefix(), self.sql_parent_name())) |
| 65 | + |
| 66 | + def sql_table_name(self): |
| 67 | + return self.config.get_mapped_table_name('{0}_{1}'.format(self.sql_prefix(), convert_to_db(self.get_current_name(), True))) |
| 68 | + |
| 69 | + def get_parent_name(self): |
| 70 | + return self._path[-2] |
| 71 | + |
| 72 | + def get_current_name(self): |
| 73 | + if self._path: |
| 74 | + return self._path[-1] |
| 75 | + return None |
| 76 | + |
| 77 | + def get_path(self): |
| 78 | + return self._path |
| 79 | + |
| 80 | + def get_path_len(self): |
| 81 | + return len(self._path) |
| 82 | + |
| 83 | +class ConfigFile: |
| 84 | + def __init__(self, filename): |
| 85 | + self.filename = filename |
| 86 | + |
| 87 | + def load(self): |
| 88 | + if not os.path.exists(self.filename): |
| 89 | + print('The all keys file %s does not exist.' % self.filename) |
| 90 | + sys.exit(1) |
| 91 | + |
| 92 | + with open(self.filename) as f: |
| 93 | + self.config = json.load(f) |
| 94 | + f.close() |
| 95 | + |
| 96 | + def get_mapped_table_name(self, generated_name): |
| 97 | + mappings = self.config['sql_table_mappings'] |
| 98 | + if not mappings or generated_name not in mappings.keys(): |
| 99 | + return generated_name |
| 100 | + |
| 101 | + return mappings[generated_name]['actual_name'] |
| 102 | + |
| 103 | + def is_ignored_parameter(self, name): |
| 104 | + ignored_parameters = self.config['ignored_parameters'] |
| 105 | + return name in ignored_parameters |
| 106 | + |
| 107 | +class KeaDatabase: |
| 108 | + def __init__(self): |
| 109 | + engine = db.create_engine('mysql+mysqldb://root@localhost/keatest') |
| 110 | + self.connection = engine.connect() |
| 111 | + |
| 112 | + def has_table(self, table_name): |
| 113 | + sql = db.text( |
| 114 | + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = :table_name" |
| 115 | + ) |
| 116 | + result = self.connection.execute(sql, {"table_name": table_name}).fetchone() |
| 117 | + return result[0] > 0 |
| 118 | + |
| 119 | + def has_column(self, table_name, column_name): |
| 120 | + sql = db.text( |
| 121 | + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = :table_name AND COLUMN_NAME = :column_name" |
| 122 | + ) |
| 123 | + result = self.connection.execute(sql, {"table_name": table_name, "column_name": column_name}).fetchone() |
| 124 | + return result[0] > 0 |
| 125 | + |
| 126 | +def traverse(state, parents, json_object): |
| 127 | + if state.config.is_ignored_parameter(state.get_current_name()): |
| 128 | + return |
| 129 | + |
| 130 | + new_parents = parents.copy() |
| 131 | + new_parents.append(json_object) |
| 132 | + |
| 133 | + comment = '' |
| 134 | + |
| 135 | + if state.get_path_len() == 1: |
| 136 | + # Top level configuration item, e.g. Dhcp4. |
| 137 | + comment = cprint(text='Top level configuration item', color='green') |
| 138 | + |
| 139 | + elif state.get_path_len() == 2 and not isinstance(json_object, list) and not isinstance(json_object, dict): |
| 140 | + # Global primitive value, e.g. boolean. Kea has a dedicated table for them. |
| 141 | + comment = cprint(text='Set primitive value {0} in {1} table'.format(state.sql_current_name(), state.sql_global_table_name()), color='green') |
| 142 | + |
| 143 | + else: |
| 144 | + # Handle primitives at lower levels. |
| 145 | + if not isinstance(json_object, dict) and not isinstance(json_object, list): |
| 146 | + if isinstance(parents[-1], dict) and isinstance(parents[-2], dict): |
| 147 | + if state.get_path_len() > 3: |
| 148 | + # If the primitive belongs to a hierarchy of two maps, the structure of |
| 149 | + # the lower level map must be flattened and the respective parameters |
| 150 | + # must be moved to the upper level map. |
| 151 | + comment = cprint(text='Create column {0} in the parent table'.format(state.sql_current_name()), color='red') |
| 152 | + else: |
| 153 | + # An exception is the parameter belonging to the top level maps, e.g. |
| 154 | + # Dhcp4/map/primitive. This primitive goes to the dhcp4_global_parameter |
| 155 | + # table. |
| 156 | + comment = cprint(text='Use global parameter {0}'.format(state.sql_current_global_name()), color='yellow') |
| 157 | + |
| 158 | + elif isinstance(parents[-1], dict) and isinstance(parents[-2], list): |
| 159 | + # A list of maps deserves its own table. For example: subnet4 or |
| 160 | + # shared_networks, option_def etc. |
| 161 | + if state.database.has_column(state.sql_parent_table_name(), state.sql_current_name()): |
| 162 | + comment = cprint(text='Column {0} in {1} table exists'.format(state.sql_current_name(), state.sql_parent_table_name()), color='green') |
| 163 | + else: |
| 164 | + comment = cprint(text='Create column {0} in {1} table'.format(state.sql_current_name(), state.sql_parent_table_name()), color='red') |
| 165 | + |
| 166 | + elif isinstance(json_object, list): |
| 167 | + if json_object and isinstance(json_object[0], dict): |
| 168 | + if state.database.has_table(state.sql_table_name()): |
| 169 | + comment = cprint(text='Table {0} exists'.format(state.sql_table_name()), color='green') |
| 170 | + else: |
| 171 | + comment = cprint(text='Create table {0}'.format(state.sql_table_name()), color='red') |
| 172 | + else: |
| 173 | + comment = cprint(text='Unable to determine children types because all-keys file contains no children for this object', color='red') |
| 174 | + |
| 175 | + elif isinstance(json_object, dict): |
| 176 | + if len(parents) > 1 and isinstance(parents[-2], dict): |
| 177 | + if state.get_path_len() == 2: |
| 178 | + comment = cprint(text='Parameters belonging to this map should be in {0}'.format(state.sql_global_table_name()), color='yellow') |
| 179 | + |
| 180 | + # Format printing the current object depending on its type. |
| 181 | + if isinstance(json_object, dict): |
| 182 | + if parents and not isinstance(parents[-1], list): |
| 183 | + # Only print the map information if the parent is not a list. Otherwise |
| 184 | + # it will be printed twice. |
| 185 | + print('{0}/dict: {1}'.format(state.get_path(), comment)) |
| 186 | + |
| 187 | + # Print each child of the map with recursion. |
| 188 | + for key in sorted(json_object.keys()): |
| 189 | + traverse(state.copy(key), parents + [json_object], json_object[key]) |
| 190 | + |
| 191 | + elif isinstance(json_object, list) and len(json_object): |
| 192 | + # If it is a list, print only the first element using recursion. |
| 193 | + # All elements of the list should have the same type, so it makes |
| 194 | + # no sense to print all of them. |
| 195 | + print('{0}/list: {1}'.format(state.get_path(), comment)) |
| 196 | + traverse(state.copy(), parents + [json_object], json_object[0]) |
| 197 | + |
| 198 | + else: |
| 199 | + # It is neither a list nor a map, so it must be a primitive. Print it |
| 200 | + # along with a comment. |
| 201 | + print('{0}/{1}: {2}'.format(state.get_path(), type(json_object).__name__, comment)) |
| 202 | + |
| 203 | +def main(): |
| 204 | + parser = argparse.ArgumentParser(description='Kea Developer Tools') |
| 205 | + parser.add_argument('--all-keys-file', metavar='all_keys_file', required=True, |
| 206 | + help='Kea all_keys.json file location.') |
| 207 | + parser.add_argument('--config-file', metavar='config_file', required=True, |
| 208 | + help='Configuration file location for this tool.') |
| 209 | + args = parser.parse_args() |
| 210 | + |
| 211 | + config = ConfigFile(args.config_file) |
| 212 | + config.load() |
| 213 | + |
| 214 | + if not os.path.exists(args.all_keys_file): |
| 215 | + print('The all keys file %s does not exist.' % args.all_keys_file) |
| 216 | + sys.exit(1) |
| 217 | + |
| 218 | + sanitized_contents = '' |
| 219 | + f = open(args.all_keys_file) |
| 220 | + for line in f: |
| 221 | + sanitized_line = line.strip() |
| 222 | + if not sanitized_line: |
| 223 | + continue |
| 224 | + |
| 225 | + if sanitized_line.find('//') != -1 or sanitized_line.find('#') != -1: |
| 226 | + continue |
| 227 | + |
| 228 | + sanitized_line = sanitized_line.replace(': .', ': 0.') |
| 229 | + sanitized_contents = sanitized_contents + sanitized_line |
| 230 | + |
| 231 | + f.close() |
| 232 | + |
| 233 | + io = StringIO(sanitized_contents) |
| 234 | + parsed = json.load(io) |
| 235 | + |
| 236 | + database = KeaDatabase() |
| 237 | + |
| 238 | + traverse(State(config, database), [], parsed) |
| 239 | + |
| 240 | +if __name__ == '__main__': |
| 241 | + main() |
0 commit comments