def apply_default_apps(self, default_apps): # These annoying names are to prevent conflicts with fields in the default app definitions PLACEMENT_KEY = 'default_placement' CONDITION_KEY = 'default_condition' for section in default_apps: if section not in self._data: self._data[section] = [] insert_idx = 0 for app in default_apps[section]: placement = app.get(PLACEMENT_KEY, 'before') if PLACEMENT_KEY in app: del app[PLACEMENT_KEY] if CONDITION_KEY in app: condition = app[CONDITION_KEY] del app[CONDITION_KEY] template = Template() tmp_vars = dict(VARS=dict(self._vars)) # Continue to next item if we have a condition and it evaluated to False if not template.evaluate_condition(condition, tmp_vars): continue if placement in ('before', 'pre'): self._data[section].insert(insert_idx, app) insert_idx += 1 elif placement in ('after', 'post'): self._data[section].append(app) else: raise DeployConfigError( 'invalid default app placement: %s' % placement)
def __init__(self, env=None): self._display = Display() self._config = self._defaults # Used for replacing vars in include paths tmp_vars = dict() if env is not None: tmp_vars['env'] = env self._template = Template(default_vars=tmp_vars)
def __init__(self, varset, output_dir, config_version): self._vars = varset self._output_dir = output_dir self._display = Display() self._template = Template() self._site_config = SiteConfig() self._config_version = config_version self.build_config()
def main(): global DISPLAY, SITE_CONFIG parser = argparse.ArgumentParser() parser.add_argument( '-c', '--config', help='Path to config file', ) parser.add_argument( '-o', '--output-dir', help="Directory to output generated docs to (defaults to '../docs')", default='../docs' ) args = parser.parse_args() DISPLAY = Display() # Verbosity of 3 is needed for displaying tracebacks DISPLAY.set_verbosity(3) SITE_CONFIG = SiteConfig() if args.config: try: SITE_CONFIG.load(args.config) except ConfigError as e: DISPLAY.display('Failed to load site config: %s' % str(e)) sys.exit(1) # Needed for instantiating output plugin classes varset = Vars() # Disable recursive variable lookups to avoid rendering templates # in field defaults from the site config tmpl = Template(recursive=False) output_plugins = load_output_plugins(varset) for plugin in output_plugins: tmp_fields = dict() for section in plugin._fields: tmp_fields[section] = get_plugin_fields(plugin._fields[section]) tmp_vars = dict( plugin=plugin, fields=tmp_fields, ) output_file = os.path.join(os.path.dirname(__file__), args.output_dir, 'plugin_%s.md' % plugin.NAME) DISPLAY.display('Writing file %s' % output_file) with open(output_file, 'w') as f: f.write(tmpl.render_template(PLUGIN_DOC_TEMPLATE, tmp_vars))
class TestTemplate(unittest.TestCase): def setUp(self): self._template = Template() def test_template_plain(self): tpl = ''' Plain text More text ''' output = self._template.render_template(inspect.cleandoc(tpl), {}) self.assertEqual(output, 'Plain text\nMore text') def test_template_var_simple(self): tpl = ''' foo {{ bar }} baz ''' my_vars = {'bar': 'whatever'} output = self._template.render_template(inspect.cleandoc(tpl), my_vars) self.assertEqual(output, 'foo whatever baz') def test_template_if_statement(self): tpl = ''' foo {% if bar is defined %} bar {% endif %} baz ''' output = self._template.render_template(inspect.cleandoc(tpl), {}) self.assertEqual(output, 'foo\n\nbaz') def test_template_filter_to_json(self): tpl = ''' {{ foo | to_json }} ''' my_vars = {'foo': {'bar': ['item 1', 'item 2']}} output = self._template.render_template(inspect.cleandoc(tpl), my_vars) self.assertEqual(output, '{"bar": ["item 1", "item 2"]}')
def load_vars(varset, deploy_dir, env='BAD_VALUE_NO_MATCH', only_site_config=False): # Used for replacing vars in file patterns template = Template() tmp_vars = dict(env=env) # Load vars from site config for key, value in list(SITE_CONFIG.default_vars.items()): if not isinstance(value, str): SITE_CONFIG.default_vars[key] = str(value) DISPLAY.warn( "implicitly converted non-string value for var '%s' from site config to string" % key) varset.update(SITE_CONFIG.default_vars) if not only_site_config: vars_path = os.path.join(deploy_dir, SITE_CONFIG.vars_dir) # Load env vars if SITE_CONFIG.use_env_vars: DISPLAY.v('Loading vars from environment') varset.update(os.environ) # Load "defaults" vars load_vars_files( varset, vars_path, template.render_template(SITE_CONFIG.defaults_vars_file_patterns, tmp_vars)) # Load env-specific vars load_vars_files( varset, vars_path, template.render_template(SITE_CONFIG.env_vars_file_patterns, tmp_vars))
def main(): global DISPLAY, SITE_CONFIG parser = argparse.ArgumentParser() parser.add_argument( 'path', help='Path to service dir', ) parser.add_argument( '-v', '--verbose', action='count', help='Increase verbosity level', default=0, ) parser.add_argument( '-c', '--config', help='Path to config file', ) parser.add_argument( '-e', '--env', help="Environment to generate deploy configs for", default='local', ) parser.add_argument( '-o', '--output-dir', help= "Directory to output generated deploy configs to (defaults to '.')", default='.') parser.add_argument( '--dump-vars', help="Output all site config vars in shell format", action='store_true', default=False, ) args = parser.parse_args() DISPLAY = Display() DISPLAY.set_verbosity(args.verbose) DISPLAY.vv('Running with args:') DISPLAY.vv() for arg in dir(args): if arg.startswith('_'): continue DISPLAY.vv('%s: %s' % (arg, getattr(args, arg))) DISPLAY.vv() SITE_CONFIG = SiteConfig(env=args.env) if args.config is None: # Possible config locations config_paths = [ os.path.join(os.environ.get('HOME', None), '.deploy-config-generator-site.yml'), ] for path in config_paths: if os.path.exists(path): args.config = path break if args.config: try: SITE_CONFIG.load(args.config) except ConfigError as e: DISPLAY.display('Failed to load site config: %s' % str(e)) show_traceback(DISPLAY.get_verbosity()) sys.exit(1) DISPLAY.vvvv('Site config:') DISPLAY.vvvv() DISPLAY.vvvv(yaml_dump(SITE_CONFIG.get_config())) DISPLAY.vvvv() varset = Vars() varset['env'] = args.env deploy_dir = None if not args.dump_vars: deploy_dir = find_deploy_dir(args.path) try: load_vars(varset, deploy_dir, args.env, only_site_config=(True if args.dump_vars else False)) except Exception as e: DISPLAY.display('Error loading vars: %s' % str(e)) show_traceback(DISPLAY.get_verbosity()) sys.exit(1) if args.dump_vars: template = Template() templated_vars = template.render_template(dict(varset), dict(VARS=dict(varset))) for key in sorted(templated_vars.keys()): print('%s=%s' % (key, shlex.quote(templated_vars[key]))) sys.exit(0) DISPLAY.vvvv() DISPLAY.vvvv('Vars:') DISPLAY.vvvv() DISPLAY.vvvv(yaml_dump(dict(varset), indent=2)) try: deploy_config = DeployConfig( os.path.join(deploy_dir, SITE_CONFIG.deploy_config_file), varset) deploy_config.set_config( varset.replace_vars(deploy_config.get_config())) except DeployConfigError as e: DISPLAY.display('Error loading deploy config: %s' % str(e)) show_traceback(DISPLAY.get_verbosity()) sys.exit(1) except VarsReplacementError as e: DISPLAY.display( 'Error loading deploy config: variable replacement error: %s' % str(e)) show_traceback(DISPLAY.get_verbosity()) sys.exit(1) DISPLAY.vvvv('Deploy config:') DISPLAY.vvvv() DISPLAY.vvvv(yaml_dump(deploy_config.get_config(), indent=2)) deploy_config_version = deploy_config.get_version( ) or SITE_CONFIG.default_config_version output_plugins = load_output_plugins(varset, args.output_dir, deploy_config_version) DISPLAY.vvv('Available output plugins:') DISPLAY.vvv() valid_sections = [] for plugin in output_plugins: DISPLAY.vvv('- %s (%s)' % (plugin.NAME, plugin.DESCR or 'No description')) valid_sections += plugin._fields.keys() DISPLAY.vvv() try: deploy_config.apply_default_apps(SITE_CONFIG.default_apps) deploy_config.validate_sections(valid_sections) except DeployConfigError as e: DISPLAY.display('Error validating deploy config: %s' % str(e)) sys.exit(1) # TODO: do validation before running each plugin so that it doesn't complain about # values populated by wrapper plugins. This is not straightforward, since it breaks # the existing code that reports on unknown fields for section in deploy_config.get_config(): for plugin in output_plugins: plugin.set_section(section) for app_idx, app in enumerate(deploy_config.get_config()[section]): app_validate_fields(app, app_idx, output_plugins) try: for plugin in output_plugins: DISPLAY.vvv('Generating output using %s plugin' % plugin.NAME) plugin.generate(deploy_config.get_config()) except DeployConfigGenerationError as e: DISPLAY.display('Failed to generate deploy config: %s' % str(e)) sys.exit(1)
class SiteConfig(object, metaclass=Singleton): _path = None _config = None _display = None _defaults = { 'default_output': None, # Directory within service dir where deploy config is located 'deploy_dir': 'deploy', # Name of deploy config file 'deploy_config_file': 'config.yml', # Directory within deploy dir to look for vars files 'vars_dir': 'var', # Patterns for finding vars files 'defaults_vars_file_patterns': [ 'defaults.var', 'defaults.yml', 'defaults.yaml', 'defaults.json', ], 'env_vars_file_patterns': [ '{{ env }}.var', 'env_{{ env }}.var', '{{ env }}.yml', '{{ env }}.yaml', '{{ env }}.json', 'env_{{ env }}.yml', 'env_{{ env }}.yaml', 'env_{{ env }}.json', ], # Whether to use vars from environment 'use_env_vars': True, # Deploy config version to assume if none is provided (defaults to latest) 'default_config_version': '1', # Additional plugins dirs 'plugin_dirs': [], # Plugin-specific options 'plugins': {}, # Default apps 'default_apps': {}, # Default vars 'default_vars': {}, } def __init__(self, env=None): self._display = Display() self._config = self._defaults # Used for replacing vars in include paths tmp_vars = dict() if env is not None: tmp_vars['env'] = env self._template = Template(default_vars=tmp_vars) def __getattr__(self, key): '''Allows object-like access to keys in the _config dict''' if key in self._config: if False and isinstance(self._config[key], dict): return objdict(self._config[key]) return self._config[key] else: raise AttributeError('No such attribute/key: %s' % key) def __getitem__(self, key): return self.__getattr__(key) def __setattr__(self, key, value): '''Allow setting of internal attributes''' if key.startswith('_'): super(SiteConfig, self).__setattr__(key, value) else: raise AttributeError('Config object is not directly writeable') def __setitem__(self, key, value): self.__setattr__(key, value) def __contains__(self, key): '''Allows using 'in' keyword''' if key in self._config: return True return False def get_config(self): return self._config def load_file(self, path): path = os.path.realpath(path) with open(path) as f: data = yaml_load(f) # Handle empty site config file # This is mostly here to allow doing '-c /dev/null' in the integration # tests to prevent them from picking up the user site config if data is None: data = {} if not isinstance(data, dict): raise ConfigError( 'config file %s should be formatted as YAML dict' % path) if 'include' in data: include_paths = self._template.render_template(data['include']) if not isinstance(include_paths, list): include_paths = [include_paths] for include_path in include_paths: if not include_path.startswith('/'): # Normalize include path based on location of parent file include_path = os.path.join(os.path.dirname(path), include_path) self._display.v('Loading site config from included file %s' % include_path) include_data = self.load_file(include_path) data = dict_merge(data, include_data) del data['include'] return data def load(self, path): try: self._display.v('Loading site config from %s' % path) self._path = os.path.realpath(path) data = self.load_file(self._path) # Special case for plugin dirs if 'plugin_dirs' in data: if not isinstance(data['plugin_dirs'], list): data['plugin_dirs'] = [data['plugin_dirs']] for idx, entry in enumerate(data['plugin_dirs']): if not entry.startswith('/'): # Normalize path based on location of site config data['plugin_dirs'][idx] = os.path.join( os.path.dirname(self._path), entry) # Special case for default apps if 'default_apps' in data: if not isinstance(data['default_apps'], dict): raise ConfigError( '"default_apps" key expects a dict, got: %s' % type(data['default_apps'])) for section, v in data['default_apps'].items(): if not isinstance(v, list): raise ConfigError( '"default_apps" key expects a dict with section names and a list of default apps' ) self._config.update(data) except Exception as e: raise ConfigError(str(e))
def setUp(self): self._template = Template()
class OutputPluginBase(object): ''' Base class for output plugins ''' _vars = None _output_dir = None _display = None _section = None _plugin_config = None _fields = None _config_version = None COMMON_DEFAULT_CONFIG = dict(enabled=True, ) PRIORITY = 1 def __init__(self, varset, output_dir, config_version): self._vars = varset self._output_dir = output_dir self._display = Display() self._template = Template() self._site_config = SiteConfig() self._config_version = config_version self.build_config() # Comparison functions for sorting plugins # Sort first by priority and then by name (for consistency) def __lt__(self, other): return (self.PRIORITY < other.PRIORITY or (self.PRIORITY == other.PRIORITY and self.NAME < other.NAME)) def __gt__(self, other): return (self.PRIORITY > other.PRIORITY or (self.PRIORITY == other.PRIORITY and self.NAME > other.NAME)) def __le__(self, other): return (self.PRIORITY <= other.PRIORITY or (self.PRIORITY == other.PRIORITY and self.NAME <= other.NAME)) def __ge__(self, other): return (self.PRIORITY >= other.PRIORITY or (self.PRIORITY == other.PRIORITY and self.NAME >= other.NAME)) def __eq__(self, other): return (self.PRIORITY == other.PRIORITY or (self.PRIORITY == other.PRIORITY and self.NAME == other.NAME)) def __ne__(self, other): return (self.PRIORITY != other.PRIORITY or (self.PRIORITY == other.PRIORITY and self.NAME != other.NAME)) def build_config(self): ''' Build the plugin config ''' self._plugin_config = self.COMMON_DEFAULT_CONFIG.copy() self._plugin_config.update(self.DEFAULT_CONFIG) # Helper var to tidy up the code self._fields = copy.deepcopy(self._plugin_config['fields']) # Convert field definitions into PluginField objects for section in self._fields: section_fields = self._fields[section] for k, v in section_fields.items(): section_fields[k] = PluginField(k, v, self._config_version, self._template) self.build_config_site() def build_config_site(self): ''' Merge in plugin config values from site config This will also do a deep merge of deeply nested field definitions ''' if self.NAME in self._site_config.plugins: for k, v in self._site_config['plugins'][self.NAME].items(): if k == 'fields': for section in v: for field_name, field in v[section].items(): # Create section if it doesn't exist if section not in self._fields: self._fields[section] = {} # Update existing field config or create new if field_name in self._fields[section]: self._fields[section][ field_name].update_config(field) else: self._fields[section][ field_name] = PluginField( field_name, field, self._config_version, self._template) else: if k in self._plugin_config: if isinstance(v, dict): self._plugin_config[k] = v.copy() elif isinstance(v, list): self._plugin_config[k] = v[:] else: self._plugin_config[k] = v else: raise ConfigError('unrecognized config option: %s' % k) def set_section(self, section): ''' Sets the active section of the deploy config This is used to figure out which set of fields to process ''' self._section = section def has_field(self, field): ''' Check if a field exists in the current section for this plugin ''' if self._section in self._fields and field in self._fields[ self._section]: if self._fields[ self._section][field].is_valid_for_config_version(): return True return False def get_required_fields(self): ''' Return a list of fields in the current section with required=True ''' ret = [] if self._section in self._fields: for k, v in self._fields[self._section].items(): if v.required and v.default is None and v.is_valid_for_config_version( ): ret.append(k) return ret def is_field_locked(self, field): ''' Check if a field has been marked as 'locked' (cannot be overridden by user) ''' if self._section in self._fields and field in self._fields[ self._section]: if self._fields[self._section][field].locked: return True return False def is_needed(self, app): ''' Determine whether this plugin is needed based on the provided deploy config ''' # We aren't needed if we're marked as disabled (enabled: False) if self._plugin_config.get('enabled', True) is False: return False # We aren't needed if we have no fields for the current section if self._section not in self._fields: return False # We are needed if we're the configured default plugin if self._site_config.default_output == self.NAME: return True # Check if any of our required top-level fields are provided for field in self.get_required_fields(): if field in app: return True # If nothing above matched, then we're probably not needed return False def merge_with_field_defaults(self, app): ''' Merge user-provided values with configured field defaults ''' ret = {} # Apply defaults/transforms for field, value in self._fields[self._section].items(): ret[field] = value.apply_default(app.get(field, None)) ret[field] = value.apply_transform(ret.get(field, None)) return ret def validate_fields(self, app): ''' Validate the provided app config against plugin field definitions ''' # Check that all required top-level fields are provided req_fields = self.get_required_fields() for field in req_fields: if field not in app: raise DeployConfigError("required field '%s' not defined" % field) # Check field/subfield types, required, and if field is locked unmatched = [] for field, value in app.items(): if self.has_field(field): if self.is_field_locked(field): raise DeployConfigError( "the field '%s' has been locked by the plugin config and cannot be overridden" % field) field_unmatched = self._fields[self._section][field].validate( value) unmatched.extend(field_unmatched) else: unmatched.append(field) return unmatched def build_app_vars(self, index, app, path=''): # Build vars for template app_vars = { 'PLUGIN_NAME': self.NAME, 'APP_INDEX': index, # App config 'APP': self.merge_with_field_defaults(app), # Parsed vars 'VARS': dict(self._vars), } return app_vars def pre_process(self, config): pass def generate(self, config): ''' Write out the generated config to disk ''' try: self.pre_process(config) for section in config: if section in self._fields: self.set_section(section) for idx, app in enumerate(config[section]): # We want a 1-based index for the output files index = idx + 1 if self.is_needed(app): # Build vars for template app_vars = self.build_app_vars(index, app) # Check conditionals for field, value in self._fields[ self._section].items(): app_vars['APP'][ field] = value.check_conditionals( app_vars['APP'].get(field, None), app_vars) # Generate output output = self.generate_output(app_vars) path_suffix = None if isinstance(output, (tuple, list)): output, path_suffix = output if output is None: continue path = os.path.join( self._output_dir, '%s-%03d%s%s' % (self.NAME, index, ('-%s' % path_suffix if path_suffix else ''), self.FILE_EXT)) self._display.v('Writing output file %s' % path) with open(path, 'w') as f: f.write(output) except Exception as e: show_traceback(self._display.get_verbosity()) raise DeployConfigGenerationError(str(e)) def generate_output(self, app_vars): ''' Generate output content By default, this renders the Jinja template defined in the 'TEMPLATE' class var. However, it can be overridden by an output plugin to provide a custom method for generating the output. ''' output = self._template.render_template( inspect.cleandoc(self.TEMPLATE), app_vars) return output