Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
bdrung committed Aug 16, 2018
0 parents commit 39ab86d
Show file tree
Hide file tree
Showing 35 changed files with 1,225 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__pycache__
/.coverage
/dist/
/htmlcov/
/*.egg-info/
ionit.1
15 changes: 15 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
ionit is licensed under ISC:

Copyright (C) 2018, Benjamin Drung <[email protected]>

Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
4 changes: 4 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
recursive-include tests *.jinja *.json *.py *.yaml echo pylint.conf
include ionit.1.md
include ionit.py
include LICENSE
131 changes: 131 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
ionit
=====

ionit is a simple and small configuration templating tool. It collects a context
and renders Jinja templates in a given directory. The context can be either
static JSON or YAML files or dynamic Python files. Python files can also define
functions passed through to the rendering.

The context filenames needs to end with `.json` for JSON, `.py` for Python,
and `.yaml` for YAML. The context files are read in alphabetical order. If the
same key is defined by multiple context files, the file that is read later takes
precedence. It is recommended to prefix the files with a number in case the
order is relevant.

ionit comes with an early boot one shot service that is executed before the
networking service which allows one to generate configurations files for the
networking and other services before they are started. In this regard, ionit can
act as tiny stepbrother of cloud-init.

Python modules
==============

Python modules can define a `collect_context` function. This function is called
by ionit and the current context is passed as parameter. The current context can
be used to derive more context information, but this variable should not be
modified. `collect_context` must return a dictionary (can be empty) or raise an
exception, which will be caught by ionit.

Python modules can also define functions which can be called from the Jinja
template on rendering. Use the `ionit_plugin.function` decorator to mark the
functions to export.

Note that the functions names should not collide with other keys from the
context. If one Python module defines a function and a value in the context
with the same name, the value in the context will take precedence.

An example Python module might look like:

```python
import ionit_plugin


@ionit_plugin.function
def double(value):
return 2 * value


@ionit_plugin.function
def example_function():
return "Lorem ipsum"


def collect_context(current_context):
return {"key": "value"}
```

Prerequisites
=============

* Python >= 3.4
* Python modules:
* jinja2
* yaml or ruamel.yaml
* pandoc (to generate `ionit.1` man page from `ionit.1.md`)

The test cases have additional requirements:

* flake8
* pylint

Examples
========

Static context
--------------

This example is taken from one test case and demonstrates how ionit will collect
the context from one JSON and one YAML file and renders one template:

```
user@host:~/ionit$ cat tests/config/static/first.json
{"first": 1}
user@host:~/ionit$ cat tests/config/static/second.yaml
second: 2
user@host:~/ionit$ cat tests/template/static/counting.jinja
Counting:
* {{ first }}
* {{ second }}
* 3
user@host:~/ionit$ ./ionit -c tests/config/static -t tests/template/static
2018-08-08 17:39:06,956 ionit INFO: Reading configuration file 'tests/config/static/first.json'...
2018-08-08 17:39:06,956 ionit INFO: Reading configuration file 'tests/config/static/second.yaml'...
2018-08-08 17:39:06,960 ionit INFO: Rendered 'tests/template/static/counting.jinja' to 'tests/template/static/counting'.
user@host:~/ionit$ cat tests/template/static/counting
Counting:
* 1
* 2
* 3
```

Python functions
----------------

This example is taken from one test case and demonstrates how Python functions
can be defined to be used when rendering:

```
user@host:~/ionit$ cat tests/config/function/function.py
import ionit_plugin
@ionit_plugin.function
def answer_to_all_questions():
return 42
user@host:~/ionit$ cat tests/template/function/Document.jinja
The answer to the Ultimate Question of Life, The Universe, and Everything is {{ answer_to_all_questions() }}.
user@host:~/ionit$ ./ionit -c tests/config/function -t tests/template/function
2018-08-13 11:58:16,905 ionit INFO: Loading Python module 'function' from 'tests/config/function/function.py'...
2018-08-13 11:58:16,909 ionit INFO: Rendered 'tests/template/function/Document.jinja' to 'tests/template/function/Document'.
user@host:~/ionit$ cat tests/template/function/Document
The answer to the Ultimate Question of Life, The Universe, and Everything is 42.
```

Contributing
============

Contributions are welcome. The source code has 100% test coverage, which should
be preserved. So please provide a test case for each bugfix and one or more
test cases for each new feature. Please follow
[How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/)
for writing good commit messages.
208 changes: 208 additions & 0 deletions ionit
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/python3

# Copyright (C) 2018, Benjamin Drung <[email protected]>
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

"""Render configuration files from Jinja templates"""

import argparse
import importlib.util
import json
import logging
import os
import sys

import jinja2
import yaml

import ionit_plugin

LOG_FORMAT = '%(asctime)s %(name)s %(levelname)s: %(message)s'
SCRIPT_NAME = "ionit"


class PythonModuleException(Exception):
"""Exception raised when loading a Python context file fails"""
pass


def load_python_plugin(file_path, current_context):
"""Collect context from given Python module
The specified Python file needs to be a valid plug-in which provides
a collect_context function that takes the current context as parameter
and returns a dict containing the context.
"""
logger = logging.getLogger(SCRIPT_NAME)
module_name = os.path.splitext(os.path.basename(file_path))[0]
logger.info("Loading Python module '%s' from '%s'...", module_name, file_path)
function_collector = ionit_plugin.FunctionCollector()
function_collector.clear()
dont_write_bytecode = sys.dont_write_bytecode
sys.dont_write_bytecode = True
try:
if sys.version_info[:2] < (3, 5):
# Backwards compatibility for Debian jessie
import imp # pragma: no cover
module = imp.load_source(module_name, file_path) # pragma: no cover
else:
spec = importlib.util.spec_from_file_location(module_name, file_path)
# module_from_spec not available in Python < 3.5.
module = importlib.util.module_from_spec(spec) # pylint: disable=no-member
spec.loader.exec_module(module)
except Exception:
logger.exception("Importing Python module '%s' failed:", file_path)
raise PythonModuleException()
finally:
sys.dont_write_bytecode = dont_write_bytecode

context = function_collector.functions.copy()
if hasattr(module, "collect_context"):
try:
new_context = module.collect_context(current_context)
except Exception:
logger.exception("Calling collect_context() from '%s' failed:", file_path)
raise PythonModuleException()
context.update(new_context)

if not context:
logger.warning("Python module '%s' does neither define a collect_context function, "
"nor export functions (using the ionit_plugin.function decorator).",
file_path)

return context


def collect_context(directory):
"""Collect context that will be used when rendering the templates"""
logger = logging.getLogger(SCRIPT_NAME)
logger.debug("Collecting context from '%s'...", directory)
try:
files = sorted(os.listdir(directory))
except OSError as error:
logger.warning("Failed to read configuration directory: %s", error)
files = []

failures = 0
context = {}

for filename in files:
file_context = None
file = os.path.join(directory, filename)
extension = os.path.splitext(filename)[1]
try:
if extension == ".json":
logger.info("Reading configuration file '%s'...", file)
with open(file) as config_file:
file_context = json.load(config_file)
elif extension == ".py":
file_context = load_python_plugin(file, context)
elif extension == ".yaml":
logger.info("Reading configuration file '%s'...", file)
with open(file) as config_file:
file_context = yaml.load(config_file)
else:
logger.info("Skipping configuration file '%s', because it does not end with "
"'.json', '.py', or '.yaml'.", file)
continue
except PythonModuleException:
failures += 1
continue
except (OSError, ValueError, yaml.error.YAMLError) as error:
types = {".json": "JSON", ".py": "Python code", ".yaml": "YAML"}
logger.error("Failed to read %s from '%s': %s", types[extension], file, error)
failures += 1
continue

logger.debug("Parsed context from '%s': %s", file, file_context)
if file_context:
try:
context.update(file_context)
except (TypeError, ValueError) as error:
logger.debug("Current context: %s", context)
logger.error("Failed to update context with content from '%s': %s",
file, error)
failures += 1
continue

return failures, context


def render_templates(template_dir, context, template_extension):
"""
Search in the template directory for template files and render them with the context
"""
logger = logging.getLogger(SCRIPT_NAME)
logger.debug("Searching in directory '%s' for Jinja templates...", template_dir)
failures = 0
env = jinja2.Environment(keep_trailing_newline=True,
loader=jinja2.FileSystemLoader(template_dir),
undefined=jinja2.StrictUndefined)
for name in env.list_templates(extensions=[template_extension]):
try:
template = env.get_template(name)
except jinja2.TemplateError:
logger.exception("Failed to load template '%s':", os.path.join(template_dir, name))
failures += 1
continue

rendered_filename = os.path.splitext(template.filename)[0]
logger.debug("Rendering template '%s' to '%s'...", template.filename, rendered_filename)
try:
rendered = template.render(context)
except Exception: # pylint: disable=broad-except
logger.exception("Failed to render '%s':", template.filename)
failures += 1
continue

try:
with open(rendered_filename, "w") as output_file:
output_file.write(rendered)
except OSError as error:
logger.error("Failed to write rendered template to '%s': %s", rendered_filename, error)
failures += 1
continue

logger.info("Rendered '%s' to '%s'.", template.filename, rendered_filename)

return failures


def main(argv):
"""Main function with argument parsing"""
parser = argparse.ArgumentParser()
parser.add_argument("-c", "--config", default="/etc/ionit",
help="Configuration directory containing context for rendering (default: "
"%(default)s)")
parser.add_argument("-t", "--templates", default="/etc",
help="Directory to search for Jinja templates (default: %(default)s)")
parser.add_argument("-e", "--template-extension", default="jinja",
help="Extension to look for in template directory (default: %(default)s)")
parser.add_argument("--debug", dest="log_level", help="Print debug output",
action="store_const", const=logging.DEBUG, default=logging.INFO)
parser.add_argument("-q", "--quiet", dest="log_level",
help="Decrease output verbosity to warnings and errors",
action="store_const", const=logging.WARNING)
args = parser.parse_args(argv)
logging.basicConfig(level=args.log_level, format=LOG_FORMAT)
logger = logging.getLogger(SCRIPT_NAME)

failures, context = collect_context(args.config)
logger.debug("Context: %s", context)
failures += render_templates(args.templates, context, args.template_extension)
return failures


if __name__ == "__main__":
sys.exit(main(sys.argv[1:])) # pragma: no cover
Loading

0 comments on commit 39ab86d

Please sign in to comment.