Skip to content

Commit

Permalink
[general] Refactored backends, renderers, added converters #70
Browse files Browse the repository at this point in the history
This is the first step in implementing #70.

This patch does not introduces changes of behaviour in the library,
infact tests remain unaltered, except for their names (because they
were named after renderer classes, which now are a much smaller
part of the library).

This patch introduces the concept of converter classes, which are
responsible for converting the NetJSON configuration dictionary
to an intermediate data structure that is then used by renderers
to be rendered.
  • Loading branch information
nemesifier committed May 25, 2017
1 parent d340d33 commit 086857b
Show file tree
Hide file tree
Showing 31 changed files with 752 additions and 569 deletions.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import gzip
import json
import tarfile
from collections import OrderedDict
from copy import deepcopy
from io import BytesIO

import six
from jinja2 import Environment, PackageLoader
from jsonschema import FormatChecker, validate
from jsonschema.exceptions import ValidationError as JsonSchemaError

from ..exceptions import ValidationError
from ..schema import DEFAULT_FILE_MODE
from ..utils import evaluate_vars, merge_config
from ...exceptions import ValidationError
from ...schema import DEFAULT_FILE_MODE
from ...utils import evaluate_vars, merge_config


class BaseBackend(object):
"""
Base Backend class
"""
schema = None
env_path = 'netjsonconfig.backends.base'
FILE_SECTION_DELIMITER = '# ---------- files ---------- #'
intermediate_data = None

def __init__(self, config, templates=[], context={}):
"""
Expand All @@ -35,8 +35,6 @@ def __init__(self, config, templates=[], context={}):
config = deepcopy(self._load(config))
self.config = self._merge_config(config, templates)
self.config = self._evaluate_vars(self.config, context)
self.env = Environment(loader=PackageLoader(self.env_path, 'templates'),
trim_blocks=True)

def _load(self, config):
"""
Expand Down Expand Up @@ -113,29 +111,23 @@ def render(self, files=True):
:returns: string with output
"""
self.validate()
output = ''
# iterate over the available renderers
# to build the target configuration
# from the configuration dictionary
for renderer_class in self.renderers:
renderer = renderer_class(self)
additional_output = renderer.render()
# add an additional new line
# to separate blocks of configuration
# generated by different renderers
if output and additional_output:
output += '\n'
# concatenate the render configuration
output += additional_output

# convert NetJSON config to intermediate data structure
if self.intermediate_data is None:
self.to_intermediate()
# render intermediate data structure into native configuration
renderer = self.renderer(self)
output = renderer.render()
# remove reference to renderer instance (not needed anymore)
del renderer
# are we required to include
# additional files?
if files:
# render additional files
files_output = self._render_files()
if files_output:
output += files_output.replace('\n\n\n', '\n\n') # max 3 \n
# finally return the whole configuration
# max 2 new lines
output += files_output.replace('\n\n\n', '\n\n')
# return the configuration
return output

def json(self, validate=True, *args, **kwargs):
Expand Down Expand Up @@ -235,59 +227,22 @@ def _add_file(self, tar, name, contents, mode=DEFAULT_FILE_MODE):
info.mode = int(mode, 8) # permissions converted to decimal notation
tar.addfile(tarinfo=info, fileobj=byte_contents)


class BaseRenderer(object):
"""
Renderers are used to generate specific configuration blocks.
"""
block_name = None

def __init__(self, backend):
self.config = backend.config
self.env = backend.env
self.backend = backend

@classmethod
def get_name(cls):
def to_intermediate(self):
"""
Get the name of the rendered without prefix
Converts the NetJSON configuration dictionary (self.config)
to the intermediate data structure (self.intermediate_data) that will
be then used by the renderer class to generate the router configuration
"""
return str(cls.__name__).replace('Renderer', '').lower()

def cleanup(self, output):
"""
Performs cleanup of output (indentation, new lines)
:param output: string representation of the client configuration
"""
return output

def render(self):
"""
Renders config block with jinja2 templating engine
"""
# get jinja2 template
template_name = '{0}.jinja2'.format(self.get_name())
template = self.env.get_template(template_name)
# render template and cleanup
context = self.get_context()
output = template.render(**context)
return self.cleanup(output)

def get_context(self):
"""
Builds context dictionary to be used in jinja2 templates
For every method prefixed with `__get`, such as ``_get_name``, creates an entry in
the context dictionary whose key is ``name`` and it's value
it the output of ``_get_name``
"""
# get list of private methods that start with "_get_"
methods = [method for method in dir(self) if method.startswith('_get_')]
context = {}
# build context
for method in methods:
key = method.replace('_get_', '')
context[key] = getattr(self, method)()
# determine if all context values are empty
context['is_empty'] = not any(context.values())
return context
self.validate()
data = OrderedDict()
for converter_class in self.converters:
# skip unnecessary loop cycles
if not converter_class.should_run(self.config):
continue
converter = converter_class(self)
for item in converter.to_intermediate():
key, value = item
if value:
data.setdefault(key, [])
data[key] += value
self.intermediate_data = data
40 changes: 40 additions & 0 deletions netjsonconfig/backends/base/converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from copy import deepcopy


class BaseConverter(object):
"""
Base Converter class
Converters are used to convert a configuration dictionary
which represent a NetJSON object to a data structure that
can be easily rendered as the final router configuration
and vice versa.
"""
netjson_key = None

def __init__(self, backend):
self.backend = backend
self.netjson = backend.config
self.intermediate_data = backend.intermediate_data

@classmethod
def should_run(cls, config):
"""
Returns True if Converter should be instantiated and run
Used to skip processing if the configuration part related to
the converter is not present in the configuration dictionary.
"""
netjson_key = cls.netjson_key or cls.__name__.lower()
return netjson_key in config

def get_copy(self, dict_, key, default=None):
"""
Looks for a key in a dictionary, if found returns
a deepcopied value, otherwise returns default value
"""
value = dict_.get(key, default)
if value:
return deepcopy(value)
return value

def to_intermediate(self):
raise NotImplementedError()
47 changes: 47 additions & 0 deletions netjsonconfig/backends/base/renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from jinja2 import Environment, PackageLoader


class BaseRenderer(object):
"""
Base Renderer class
Renderers are used to generate a string
which represents the router configuration
"""
def __init__(self, backend):
self.config = backend.config
self.backend = backend

@property
def env_path(self):
return self.__module__

@property
def template_env(self):
return Environment(loader=PackageLoader(self.env_path, 'templates'), trim_blocks=True)

@classmethod
def get_name(cls):
"""
Returns the name of the render class without its prefix
"""
return str(cls.__name__).replace('Renderer', '').lower()

def cleanup(self, output):
"""
Performs cleanup of output (indentation, new lines)
:param output: string representation of the client configuration
"""
return output

def render(self):
"""
Renders configuration by using the jinja2 templating engine
"""
# get jinja2 template
template_name = '{0}.jinja2'.format(self.get_name())
template = self.template_env.get_template(template_name)
# render template and cleanup
context = getattr(self.backend, 'intermediate_data', {})
output = template.render(data=context)
return self.cleanup(output)
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
from copy import deepcopy

from ...utils import sorted_dict
from ..base import BaseRenderer
from ..base.converter import BaseConverter


class OpenVpnRenderer(BaseRenderer):
"""
Produces an OpenVPN configuration string
"""
def cleanup(self, output):
# remove indentations
output = output.replace(' ', '')
# remove last newline
if output.endswith('\n\n'):
output = output[0:-1]
return output
class OpenVpn(BaseConverter):
def to_intermediate(self):
result = []
for vpn in self.get_copy(self.netjson, 'openvpn'):
result.append(sorted_dict(self.__get_vpn(vpn)))
return (('openvpn', result),)

def _transform_vpn(self, vpn):
config = deepcopy(vpn)
def __get_vpn(self, config):
skip_keys = ['script_security', 'remote']
delete_keys = []
# allow server_bridge to be empty and still rendered
Expand All @@ -41,10 +33,3 @@ def _transform_vpn(self, vpn):
if 'status' not in config and 'status_version' in config:
del config['status_version']
return config

def _get_openvpn(self):
openvpn = []
for vpn in self.config.get('openvpn', []):
config = self._transform_vpn(vpn)
openvpn.append(sorted_dict(config))
return openvpn
15 changes: 9 additions & 6 deletions netjsonconfig/backends/openvpn/openvpn.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import re

from ..base import BaseBackend
from .renderers import OpenVpnRenderer
from . import converters
from ..base.backend import BaseBackend
from .parser import OpenVpnParser
from .renderer import OpenVpnRenderer
from .schema import schema


class OpenVpn(BaseBackend):
"""
OpenVPN 2.3 backend
OpenVPN 2.x Configuration Backend
"""
schema = schema
env_path = 'netjsonconfig.backends.openvpn'
renderers = [OpenVpnRenderer]
converters = [converters.OpenVpn]
parser = OpenVpnParser
renderer = OpenVpnRenderer
VPN_REGEXP = re.compile('# openvpn config: ')

def _generate_contents(self, tar):
Expand All @@ -26,7 +29,7 @@ def _generate_contents(self, tar):
vpn_instances = self.VPN_REGEXP.split(text)
if '' in vpn_instances:
vpn_instances.remove('')
# for each package create a file with its contents in /etc/config
# create a file for each VPN
for vpn in vpn_instances:
lines = vpn.split('\n')
vpn_name = lines[0]
Expand Down
14 changes: 14 additions & 0 deletions netjsonconfig/backends/openvpn/renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from ..base.renderer import BaseRenderer


class OpenVpnRenderer(BaseRenderer):
"""
OpenVPN Renderer
"""
def cleanup(self, output):
# remove indentations
output = output.replace(' ', '')
# remove last newline
if output.endswith('\n\n'):
output = output[0:-1]
return output
2 changes: 1 addition & 1 deletion netjsonconfig/backends/openvpn/templates/openvpn.jinja2
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% for vpn in openvpn %}
{% for vpn in data.openvpn %}
# openvpn config: {{ vpn.pop('name') }}

{% for key, value in vpn.items() %}
Expand Down
20 changes: 7 additions & 13 deletions netjsonconfig/backends/openwisp/openwisp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@


class OpenWisp(OpenWrt):
""" OpenWisp 1.x Backend """
"""
OpenWISP 1.x Firmware (legacy) Configuration Backend
"""
schema = schema
openwisp_env = Environment(loader=PackageLoader('netjsonconfig.backends.openwisp', 'templates'),
trim_blocks=True)

def validate(self):
self._sanitize_radios()
Expand All @@ -26,7 +26,9 @@ def _sanitize_radios(self):
radio.setdefault('disabled', False)

def _render_template(self, template, context={}):
template = self.openwisp_env.get_template(template)
openwisp_env = Environment(loader=PackageLoader(self.__module__, 'templates'),
trim_blocks=True)
template = openwisp_env.get_template(template)
return template.render(**context)

def _add_unique_file(self, item):
Expand Down Expand Up @@ -140,14 +142,6 @@ def _add_tc_script(self):
"mode": "755"
})

def generate(self):
"""
Generates an openwisp configuration archive.
:returns: in-memory tar.gz archive, instance of ``BytesIO``
"""
return super(OpenWisp, self).generate()

def _generate_contents(self, tar):
"""
Adds configuration files to tarfile instance.
Expand All @@ -160,7 +154,7 @@ def _generate_contents(self, tar):
packages = re.split('package ', uci)
if '' in packages:
packages.remove('')
# for each package create a file with its contents in /etc/config
# create a file for each configuration package used
for package in packages:
lines = package.split('\n')
package_name = lines[0]
Expand Down
Loading

0 comments on commit 086857b

Please sign in to comment.