#!/usr/bin/env python3 from argparse import ArgumentParser from pathlib import Path from yaml import safe_load from json import dumps from traceback import print_exc from os import environ import logging from rich.tree import Tree from rich.console import Console from rich.table import Table from rich.panel import Panel from ansible.parsing.vault import VaultLib, PromptVaultSecret from ansible.module_utils._text import to_bytes from tiramisu import PasswordOption, Config from tiramisu.error import ValueOptionError, PropertiesOptionError, LeadershipError from tiramisu.i18n import _ import rougail from rougail import Rougail, RougailConfig from rougail.utils import normalize_family from rougail.annotator import CONVERT_OPTION NAMESPACE = "socle" NAMESPACE_HOSTS = "hosts" VERSION_FILE = None def tiramisu_display_name(kls) -> str: """Replace the Tiramisu display_name function to display path + description""" try: doc = kls.impl_get_information("doc", None) name = kls.impl_getname() except AttributeError: doc = kls.opt.impl_get_information('doc', None) name = kls.opt.impl_getname() comment = f" ({doc})" if doc and doc != name else "" return f"{kls.impl_getpath()}{comment}" rougail.tiramisu_display_name = tiramisu_display_name class DSOFPasswordOption(PasswordOption): def __init__(self, *args, min_len=12, max_len=None, forbidden_char=[], **kwargs): extra = {} extra = {'min_len': min_len} if max_len is not None: extra['max_len'] = max_len if forbidden_char: extra['forbidden_char'] = set(forbidden_char) super().__init__(*args, extra=extra, **kwargs) def validate(self, value: str) -> None: super().validate(value) if len(value) <= self.impl_get_extra('min_len'): raise ValueError(f'il faut au minimum {self.impl_get_extra("min_len")} caractères') max_len = self.impl_get_extra('max_len') if max_len and len(value) > max_len: raise ValueError(f'il faut au maximum {max_len} caractères') if self.impl_get_extra("forbidden_char"): forbidden_char = set(value) & self.impl_get_extra("forbidden_char") if forbidden_char: raise ValueError(f'ne doit pas avoir les caracteres speciaux {",".join(forbidden_char)}') CONVERT_OPTION["secret"] = dict(opttype="DSOFPasswordOption") class DSOFRougail(Rougail): def get_config(self): """Get Tiramisu Config""" if not self.config: tiram_obj = self.converted.save(self.rougailconfig["tiramisu_cache"]) optiondescription = {} exec(tiram_obj, {"DSOFPasswordOption": DSOFPasswordOption}, optiondescription) # pylint: disable=W0122 self.config = Config( optiondescription["option_0"], display_name=tiramisu_display_name, ) self.config.property.read_write() for mode in self.rougailconfig['modes_level']: self.config.permissive.add(mode) return self.config class Inventory: ############################################### # Create TIRAMISU object ############################################### def __init__(self, args, for_doc=False): self.args = args sub_inventory_dir = Path('inventaires') / 'rougail' self.inside_git_dir = False inventory_dir = None if for_doc: socle_inventory_dir = Path(__file__).parent.parent / 'inventaires' / "rougail" if socle_inventory_dir.is_dir: inventory_dir = str(socle_inventory_dir) if not inventory_dir and not sub_inventory_dir.is_dir(): if self.args.debug: print(f'cannot find {sub_inventory_dir}') for git_file in [Path.cwd().parent / 'inventaires' / "rougail", Path.cwd() / 'packaging' / 'inventaires' / "rougail", ]: if git_file.is_dir(): if self.args.debug: print('inside git dir !') self.inside_git_dir = True inventory_dir = git_file break if not inventory_dir: inventory_dir = sub_inventory_dir RougailConfig['dictionaries_dir'] = [inventory_dir] RougailConfig['variable_namespace'] = NAMESPACE RougailConfig['extra_dictionaries'][NAMESPACE_HOSTS] = ['inventaires/hosts/'] if for_doc: project_name = Path().cwd().name.rsplit('-', 1)[0] versions = {project_name: {'docker_version': None}} else: with VERSION_FILE.open() as fh: versions = safe_load(fh) for project, version in versions.items(): if "docker_version" not in version: continue project_name = self.get_project_name(project) # get the first directory in ~/DSOF-SIx-xxxx// if for_doc: root_path = Path.cwd() / 'packaging' elif self.inside_git_dir: root_path = Path.cwd().parent.parent.parent / (project + '-packaging') / 'packaging' else: root_path = Path.home() / project / version['packaging_version'] if root_path.is_dir(): root_path = next(root_path.iterdir()) path = root_path / sub_inventory_dir if path.is_dir(): RougailConfig['extra_dictionaries'][project_name] = [str(path)] elif self.args.debug: print(f'cannot find {path}') #FIXME #RougailConfig['tiramisu_cache'] = 'socle.py' RougailConfig['functions_file'] = 'Rougail/functions.py' self.errors = [] self.warnings = [] self.console = Console(force_terminal=True) def get_project_name(self, project): return normalize_family(project.split('-', 2)[-1]) def load(self): rougail = DSOFRougail() self.conf = rougail.get_config() self.conf.property.read_write() self.objectspace = rougail.converted # self.objectspace.annotate() ############################################### # Read Ops file ############################################### def load_custom(self): if not CUSTOM_FILE or not CUSTOM_FILE.is_file(): if self.args.debug: print(f'cannot find {CUSTOM_FILE}') return prompt = PromptVaultSecret(INVENTORY_PASSWORD, 'default', ["Vault password: "]) if not INVENTORY_PASSWORD: prompt.load() vault = VaultLib([('default', prompt)]) with CUSTOM_FILE.open('rb') as fh: values = safe_load(vault.decrypt(fh.read())) for key, value in values.items(): self.read_inventory(self.conf, key, value, CUSTOM_FILE, ) def set_env(self): if not CURRENT_ENV and self.inside_git_dir: current_env = 'git' else: current_env = CURRENT_ENV if not current_env: self.errors.append(f"la variable d'environnement \"current_env\" est obligatoire") return self.conf.option(f"socle.env_name").value.set(current_env) def set_versions(self): if self.errors: return with VERSION_FILE.open() as fh: versions = safe_load(fh) for project, version in versions.items(): project_name = self.get_project_name(project) if "docker_version" not in version: continue try: self.conf.option(f"{project_name}.compose.image_tag").value.set(version["docker_version"]) except AttributeError: pass def read_inventory(self, conf, key, value, file, *, index=None, ): sub_conf = conf.option(key, index) try: isoptiondescription = sub_conf.isoptiondescription() except AttributeError as err: # ugly try: try: paths, options = conf.get()._children except: paths, options = conf.get().opt._children if '{{ suffix }}' in paths: option = options[paths.index('{{ suffix }}')] path = option._suffixes.params.args[0].option._path # ugly, assume that dynamic variable are in parent family if '{{ suffix }}' in path: cnt = path.count('.') sub_path = sub_conf._path subcnt = sub_path.count('.') path = sub_path.rsplit('.', subcnt - cnt + 1)[0] + '.' + path.rsplit('.', 1)[-1] values = self.conf.option(path).value.get() key = normalize_family(key) values.append(key) self.conf.option(path).value.set(values) self.read_inventory(conf, key, value, file, index=index) return except Exception: if args.debug: print_exc() pass if conf == self.conf: path = NAMESPACE else: path = conf.path() self.warnings.append(f'"{key}" est inconnu dans "{path}" mais est défini dans "{file}"') return except LeadershipError as err: if args.debug: print_exc() self.errors.append(str(err)) return if isoptiondescription: if not sub_conf.isleadership(): if not isinstance(value, dict): print('pffff1') return for sub_key, sub_value in value.items(): self.read_inventory(sub_conf, sub_key, sub_value, file, ) else: if not isinstance(value, list): return leader_option = sub_conf.leader().name() leader_value = [] for leader in value: if leader_option not in leader: self.warnings.append(f'cannot find leader "{sub_conf.leader().path()} in {list(leader)}') return leader_value.append(leader[leader_option]) self.read_inventory(sub_conf, leader_option, leader_value, file, ) for idx, sub_value in enumerate(value): for sub_key, sub_value in sub_value.items(): if sub_key == leader_option: continue self.read_inventory(sub_conf, sub_key, sub_value, file, index=idx, ) else: try: # sub_conf.permissive.set(frozenset(['advanced'])) sub_conf.value.set(value) except ValueOptionError as err: if args.debug: print_exc() self.errors.append(str(err).replace('"', "'")) except PropertiesOptionError as err: if args.debug: print_exc() self.warnings.append(f'"{err}" mais est défini dans "{file}"') ############################################### # Host ############################################### def get_hosts(self): ret = {"_meta": {"hostvars": {}}, "all": {"children": ["ungrouped"]}} if self.errors: ret["_meta"]["hostvars"]["localhost"] = {'_errors': self.errors} ret["ungrouped"] = {"hosts": ["localhost"]} else: inventories = {} self.conf.property.read_only() for line, value in self.conf.value.get().items(): self.parse_line(line, line, '', value, inventories, False, False, ) if NAMESPACE_HOSTS not in inventories: return ret hostnames = inventories[NAMESPACE_HOSTS]['hostnames'] ret_hosts = {} for name, hosts in hostnames.items(): if 'hosts' in hosts: for idx, host in enumerate(hosts['hosts']): index = str(idx + 1) if idx < 9: index = '0' + index host_name = hosts['prefix_name'] + index ret_hosts.setdefault(name, {})[host_name] = host ret.setdefault(name, {}).setdefault('hosts', []).append(host_name) else: ret["all"]["children"].append(name) ret[name] = hosts for hosts in ret_hosts.values(): for host, domain_name in hosts.items(): ret['_meta']['hostvars'][host] = {'ansible_host': domain_name} ret['_meta']['hostvars'][host].update(inventories) #if CURRENT_NAMESPACE != NAMESPACE: # ret['_meta']['hostvars'][host][CURRENT_NAMESPACE] = inventories[CURRENT_NAMESPACE] # print(ret_hosts) # ret['_meta']['hostvars']['localhost'] = inventories return ret def display_hosts(self): self.conf.property.read_only() hostnames = self.conf.option(NAMESPACE_HOSTS).option('hostnames') ret_hosts = {} for optiondescription in hostnames.list('optiondescription'): hosts = optiondescription.value.get() try: prefix_name = optiondescription.option('prefix_name').value.get() except AttributeError: # it's a group continue for idx, host in enumerate(optiondescription.option('hosts').value.get()): index = str(idx + 1) if idx < 9: index = '0' + index host_name = prefix_name + index ret_hosts[host_name] = host print() header = Table(show_header=False, title="Liste des adresses") for key, value in ret_hosts.items(): header.add_row(key, value) self.console.print(header) ############################################### # Search unspecified mandatories variables ############################################### def mandatory(self): title = False options_with_error = [] for option in self.conf.value.mandatory(): try: option.value.get() if not title: self.errors.append("Les variables suivantes sont obligatoires mais n'ont pas de valeur :") title = True self.errors.append(f' - {option.doc()}') except PropertiesOptionError: options_with_error.append(option) if not title: for idx, option in enumerate(options_with_error): if not idx: self.errors.append("Les variables suivantes sont inaccessibles mais sont vides :") self.errors.append(f' - {option.doc()}') ############################################### # Tiramisu to inventory ############################################### def rich(self, ret, tree): for key, value in ret.items(): if isinstance(value, dict): subtree = tree.add(f":open_file_folder: {key}", guide_style="bold bright_blue", ) self.rich(value, subtree) elif isinstance(value, list): subtree = tree.add(f":notebook: {key} :", guide_style="bold bright_blue", ) for val in value: subtree.add(str(val)) else: tree.add(f":notebook: {key} : {value}") def rich_display(self): header_variable = 'Variable\n' header_variable += '[bright_blue]Variable non documentée[/bright_blue]\n' header_variable += '[red1]Variable non documentée mais modifiée[/red1]' if self.args.read_only: header_variable += '\n[orange1]Variable non modifiable[/orange1]' header_value = '[gold1]Valeur par défaut[/gold1]\n' header_value += 'Valeur modifiée\n' header_value += '([red1]Valeur par défaut originale[/red1])' header = Table.grid(padding=1, collapse_padding=True) header.pad_edge = False header.add_row(header_variable, header_value) header = Panel.fit(header, title="Légende") self.console.print(header) inventories = {} if self.args.read_only: self.conf.property.read_only() else: self.conf.property.read_write() for line, value in self.conf.value.get().items(): self.parse_line(line, line, '', value, inventories, False, False, for_doc=True, ) for warning in self.warnings: self.console.print(Tree(f":warning: {warning}")) if self.errors: self.display_errors() tree = Tree(":open_file_folder: Inventaire", guide_style="bold bright_blue", ) self.rich(inventories, tree) self.console.print(tree) self.display_hosts() def display_errors(self): tree = Tree(":stop_sign: ERREURS", guide_style="bold bright_red", ) for error in self.errors: tree.add(error) self.console.print(tree) exit(1) def parse_line(self, full_path, line, parent_path, value, dico, leadership, family_hidden, *, for_doc=False, ): if '.' in line: # it's a dict family, variable = line.split('.', 1) current_path = parent_path if current_path: current_path += '.' current_path += family if for_doc: if 'hidden' in self.conf.option(current_path).property.get() or family_hidden: family_hidden = True family = f'[orange1]{family}[/orange1]' elif 'advanced' in self.conf.option(current_path).property.get(): family = f'[bright_blue]{family}[/bright_blue]' if '.' not in variable and self.conf.option(full_path.rsplit('.', 1)[0]).isleadership(): dico.setdefault(family, []) leadership = True else: dico.setdefault(family, {}) leadership = False self.parse_line(full_path, variable, current_path, value, dico[family], leadership, family_hidden, for_doc=for_doc, ) elif leadership: # it's a leadership for idx, val in enumerate(value): dic = {k.rsplit('.', 1)[-1]: v for k, v in val.items()} if for_doc: leader = True for k, v in val.items(): if leader: is_default = self.conf.option(k).owner.isdefault() properties = self.conf.option(k).property.get() else: is_default = self.conf.option(k, idx).owner.isdefault() properties = self.conf.option(k, idx).property.get() if self.conf.option(k).type() == _('password') and not self.args.show_password: v = "*" * 10 subpath = k.rsplit('.', 1)[-1] if 'hidden' in properties or family_hidden: subpath = f'[orange1]{subpath}[/orange1]' elif 'advanced' in properties: if isdefault: subpath = f'[bright_blue]{subpath}[/bright_blue]' else: subpath = f'[red1]{subpath}[/red1]' if is_default: v = '[gold1]' + str(v) + '[/gold1]' dico.append(f'{subpath}: {v}') leader = False else: dico.append(dic) else: # it's a variable is_default = self.conf.option(full_path).owner.isdefault() default_value = None if for_doc: mod_is_red = False if not is_default: true_default_value = self.conf.option(full_path).value.default() if true_default_value and true_default_value != value: if isinstance(true_default_value, list): default_value = [f' ([red1]{true}[/red1])' for true in true_default_value] else: default_value = f' ([red1]{true_default_value}[/red1])' mod_is_red = True if self.conf.option(full_path).type() == _('password') and not self.args.show_password: if isinstance(value, list): value = ["*" * 10 for val in value] else: value = "*" * 10 if 'hidden' in self.conf.option(full_path).property.get() or family_hidden: line = f'[orange1]{line}[/orange1]' elif 'advanced' in self.conf.option(full_path).property.get(): if not mod_is_red: line = f'[bright_blue]{line}[/bright_blue]' else: line = f'[red1]{line}[/red1]' if is_default: if isinstance(value, list): dico[line] = ['[gold1]' + str(val) + '[/gold1]' for val in value] else: dico[line] = '[gold1]' + str(value) + '[/gold1]' if (for_doc and not is_default) or not for_doc: if default_value: if isinstance(value, list): len_value = len(value) len_default_value = len(default_value) len_values = max(len_value, len_default_value) new_value = [] for idx in range(len_values): new = '' if idx < len_value: new += value[idx] if idx < len_default_value: new += default_value[idx] new_value.append(new) value = new_value else: value = str(value) + default_value dico[line] = value def main(args): inventory = Inventory(args) inventory.load() inventory.load_custom() inventory.set_env() inventory.set_versions() if inventory.errors: inventory.display_errors() if args.hosts: inventory.display_hosts() elif args.list: print(dumps(inventory.get_hosts(), ensure_ascii=False, indent=2) ) else: if not args.no_mandatory: inventory.mandatory() if args.host: # inventory is already set during --list print(dumps({})) else: inventory.rich_display() def get_argsparse(): parser = ArgumentParser() parser.add_argument('--list', action='store_true') parser.add_argument('--host', action='store') parser.add_argument('--hosts', action='store_true') parser.add_argument('--debug', action='store_true') parser.add_argument('--read_only', action='store_true') parser.add_argument('--no_mandatory', action='store_true') parser.add_argument('--show_password', action='store_true') args = parser.parse_args() if args.debug: level = logging.DEBUG logging.basicConfig( level=level, format='%(asctime)s - %(levelname)s - %(message)s' ) return args if __name__ == "__main__": if 'CURRENT_ENV' not in environ: CURRENT_ENV = environ.get('current_env') else: CURRENT_ENV = environ['CURRENT_ENV'] if 'VERSION_FILE' not in environ: VERSION_FILE = Path('versions.yml') else: VERSION_FILE = Path(environ['VERSION_FILE']) if 'INVENTORY_PASSWORD' in environ: INVENTORY_PASSWORD = environ['INVENTORY_PASSWORD'] INVENTORY_PASSWORD = to_bytes(INVENTORY_PASSWORD, errors='strict', nonstring='simplerepr').strip() else: INVENTORY_PASSWORD = None if CURRENT_ENV: CUSTOM_FILE = Path.cwd().parent.parent / 'inventaires' / CURRENT_ENV / "custom.yml" else: CUSTOM_FILE = Path("custom.yml") if not VERSION_FILE.is_file(): raise Exception(f'cannot find VERSION_FILE "{VERSION_FILE}"') try: args = get_argsparse() except Exception as err: print(dumps({'_errors': str(err)}, ensure_ascii=False, indent=2)) exit(1) try: main(args) except Exception as err: if args.debug: print_exc() if args.list or args.host: print(dumps({'_errors': str(err)}, ensure_ascii=False, indent=2)) exit(1) else: tree = Tree(":stop_sign: ERREURS", guide_style="bold bright_red", ) tree.add(str(err)) Console().print(tree) exit(1)