diff --git a/octavia/amphorae/drivers/haproxy-simple/__init__.py b/octavia/amphorae/drivers/haproxy/__init__.py similarity index 100% rename from octavia/amphorae/drivers/haproxy-simple/__init__.py rename to octavia/amphorae/drivers/haproxy/__init__.py diff --git a/octavia/amphorae/drivers/haproxy/jinja/__init__.py b/octavia/amphorae/drivers/haproxy/jinja/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/octavia/amphorae/drivers/haproxy/jinja/jinja_cfg.py b/octavia/amphorae/drivers/haproxy/jinja/jinja_cfg.py new file mode 100644 index 0000000000..83f7bcc28d --- /dev/null +++ b/octavia/amphorae/drivers/haproxy/jinja/jinja_cfg.py @@ -0,0 +1,245 @@ +# Copyright (c) 2015 Rackspace +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os + +import jinja2 +import six + +from octavia.common import constants + +PROTOCOL_MAP = { + constants.PROTOCOL_TCP: 'tcp', + constants.PROTOCOL_HTTP: 'http', + constants.PROTOCOL_HTTPS: 'tcp', + constants.PROTOCOL_TERMINATED_HTTPS: 'http' +} + +BALANCE_MAP = { + constants.LB_ALGORITHM_ROUND_ROBIN: 'roundrobin', + constants.LB_ALGORITHM_LEAST_CONNECTIONS: 'leastconn', + constants.LB_ALGORITHM_SOURCE_IP: 'source' +} + +ACTIVE_PENDING_STATUSES = constants.SUPPORTED_PROVISIONING_STATUSES + ( + constants.DEGRADED,) + +BASE_PATH = '/var/lib/octavia' +BASE_CRT_DIR = '/listeners' + +HAPROXY_TEMPLATE = os.path.abspath( + os.path.join(os.path.dirname(__file__), + 'templates/haproxy_listener.template')) + +JINJA_ENV = None + + +class JinjaTemplater(object): + + def __init__(self, base_amp_path=None, + base_crt_dir=None, + haproxy_template=None): + + """:param base_amp_path: Base path for amphora data + + :param base_crt_dir: Base directory for certificate storage + + :param haproxy_template: Absolute path to the Jinja template for + + HaProxy configuration generation + + """ + + self.base_amp_path = base_amp_path if base_amp_path else BASE_PATH + self.base_crt_dir = base_crt_dir if base_crt_dir else BASE_CRT_DIR + self.haproxy_template = (haproxy_template if haproxy_template + else HAPROXY_TEMPLATE) + self.cert_store_path = '{0}{1}'.format(self.base_amp_path, + self.base_crt_dir) + + def build_config(self, listener, tls_cert, + socket_path=None, + user_group='nogroup'): + """Convert a logical configuration to the HAProxy version.""" + return self.render_loadbalancer_obj(listener, + tls_cert=tls_cert, + user_group=user_group, + socket_path=socket_path) + + def _get_template(self): + """Returns the specified Jinja configuration template.""" + global JINJA_ENV + if not JINJA_ENV: + template_loader = jinja2.FileSystemLoader( + searchpath=os.path.dirname(self.haproxy_template)) + JINJA_ENV = jinja2.Environment( + loader=template_loader, + trim_blocks=True, + lstrip_blocks=True) + return JINJA_ENV.get_template(os.path.basename(self.haproxy_template)) + + def render_loadbalancer_obj(self, listener, + tls_cert=None, + user_group='nogroup', + socket_path=None): + """Renders a templated configuration from a load balancer object.""" + loadbalancer = self._transform_loadbalancer( + listener.loadbalancer, + listener, + tls_cert) + if not socket_path: + socket_path = '%s/%s.sock' % (self.base_amp_path, listener.id) + return self._get_template().render( + {'loadbalancer': loadbalancer, + 'user_group': user_group, + 'stats_sock': socket_path}, + constants=constants) + + def _transform_loadbalancer(self, loadbalancer, listener, tls_cert): + """Transforms a load balanacer into an object that will + + be processed by the templating system + """ + listener = self._transform_listener(listener, tls_cert) + return { + 'name': loadbalancer.name, + 'vip_address': loadbalancer.vip.ip_address, + 'listener': listener + } + + def _transform_listener(self, listener, tls_cert): + """Transforms a listener into an object that will + + be processed by the templating system + """ + ret_value = { + 'id': listener.id, + 'protocol_port': listener.protocol_port, + 'protocol_mode': PROTOCOL_MAP[listener.protocol], + 'protocol': listener.protocol + } + if listener.connection_limit and listener.connection_limit > -1: + ret_value['connection_limit'] = listener.connection_limit + if listener.tls_container_id: + ret_value['default_tls_path'] = '%s/%s/%s.pem' % ( + self.cert_store_path, listener.id, tls_cert.primary_cn) + if listener.sni_containers: + ret_value['crt_dir'] = '%s/%s' % ( + self.cert_store_path, listener.id) + if listener.default_pool: + ret_value['default_pool'] = self._transform_pool( + listener.default_pool) + return ret_value + + def _transform_pool(self, pool): + """Transforms a pool into an object that will + + be processed by the templating system + """ + ret_value = { + 'id': pool.id, + 'protocol': PROTOCOL_MAP[pool.protocol], + 'lb_algorithm': BALANCE_MAP.get(pool.lb_algorithm, 'roundrobin'), + 'members': [], + 'health_monitor': '', + 'session_persistence': '', + 'enabled': pool.enabled, + 'operating_status': pool.operating_status + } + members = [self._transform_member(x) + for x in pool.members if self._include_member(x)] + ret_value['members'] = members + if pool.healthmonitor: + ret_value['health_monitor'] = self._transform_health_monitor( + pool.healthmonitor) + if pool.sessionpersistence: + ret_value[ + 'session_persistence'] = self._transform_session_persistence( + pool.sessionpersistence) + return ret_value + + def _transform_session_persistence(self, persistence): + """Transforms session persistence into an object that will + + be processed by the templating system + """ + return { + 'type': persistence.type, + 'cookie_name': persistence.cookie_name + } + + def _transform_member(self, member): + """Transforms a member into an object that will + + be processed by the templating system + """ + return { + 'id': member.id, + 'address': member.ip_address, + 'protocol_port': member.protocol_port, + 'weight': member.weight, + 'enabled': member.enabled, + 'subnet_id': member.subnet_id, + 'operating_status': member.operating_status + } + + def _transform_health_monitor(self, monitor): + """Transforms a health monitor into an object that will + + be processed by the templating system + """ + return { + 'id': monitor.id, + 'type': monitor.type, + 'delay': monitor.delay, + 'timeout': monitor.timeout, + 'fall_threshold': monitor.fall_threshold, + 'http_method': monitor.http_method, + 'url_path': monitor.url_path, + 'expected_codes': '|'.join( + self._expand_expected_codes(monitor.expected_codes)), + 'enabled': monitor.enabled, + } + + def _include_member(self, member): + """Members that should be included + + Return only those that meet the criteria for templating + """ + return ( + member.operating_status in + ACTIVE_PENDING_STATUSES and + member.enabled + ) + + def _expand_expected_codes(self, codes): + """Expand the expected code string in set of codes. + + 200-204 -> 200, 201, 202, 204 + 200, 203 -> 200, 203 + """ + + retval = set() + for code in codes.replace(',', ' ').split(' '): + code = code.strip() + + if not code: + continue + elif '-' in code: + low, hi = code.split('-')[:2] + retval.update( + str(i) for i in six.moves.xrange(int(low), int(hi) + 1)) + else: + retval.add(code) + return retval diff --git a/octavia/amphorae/drivers/haproxy/jinja/templates/__init__.py b/octavia/amphorae/drivers/haproxy/jinja/templates/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_base.template b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_base.template new file mode 100644 index 0000000000..8dadb59731 --- /dev/null +++ b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_base.template @@ -0,0 +1,33 @@ +{# # Copyright (c) 2015 Rackspace +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +#} +# Configuration for {{ loadbalancer_name }} +global + daemon + user nobody + group {{ usergroup }} + log /dev/log local0 + log /dev/log local1 notice + stats socket {{ sock_path }} mode 0666 level user + +defaults + log global + retries 3 + option redispatch + timeout connect 5000 + timeout client 50000 + timeout server 50000 + +{% block proxies %}{% endblock proxies %} diff --git a/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_listener.template b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_listener.template new file mode 100644 index 0000000000..040ffae947 --- /dev/null +++ b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_listener.template @@ -0,0 +1,27 @@ +{# # Copyright (c) 2015 Rackspace +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +#} +{% extends 'haproxy_proxies.template' %} +{% set loadbalancer_name = loadbalancer.name %} +{% set usergroup = user_group %} +{% set sock_path = stats_sock %} + +{% block proxies %} +{% from 'haproxy_proxies.template' import frontend_macro as frontend_macro, backend_macro%} +{{ frontend_macro(constants, loadbalancer.listener, loadbalancer.vip_address) }} +{% if loadbalancer.listener.default_pool %} +{{ backend_macro(constants, loadbalancer.listener, loadbalancer.listener.default_pool) }} +{% endif %} +{% endblock proxies %} \ No newline at end of file diff --git a/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_loadbalancer.template b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_loadbalancer.template new file mode 100644 index 0000000000..cbc6cbfb91 --- /dev/null +++ b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_loadbalancer.template @@ -0,0 +1,29 @@ +{# # Copyright (c) 2015 Rackspace +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +#} +{% extends 'haproxy_proxies.template' %} +{% set loadbalancer_name = loadbalancer.name %} +{% set usergroup = user_group %} +{% set sock_path = stats_sock %} + +{% block proxies %} +{% from 'haproxy_proxies.template' import frontend_macro as frontend_macro, backend_macro%} +{% for listener in loadbalancer.listeners %} +{{ frontend_macro(constants, listener, loadbalancer.vip_address) }} +{% if listener.default_pool %} +{{ backend_macro(constants, listener, listener.default_pool) }} +{% endif %} +{% endfor %} +{% endblock proxies %} \ No newline at end of file diff --git a/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_proxies.template b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_proxies.template new file mode 100644 index 0000000000..4f38f0467d --- /dev/null +++ b/octavia/amphorae/drivers/haproxy/jinja/templates/haproxy_proxies.template @@ -0,0 +1,95 @@ +{# # Copyright (c) 2015 Rackspace +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +#} +{% extends 'haproxy_base.template' %} + +{% macro bind_macro(constants, listener, lb_vip_address) %} +{% if listener.default_tls_path %} +{% set def_crt_opt = "ssl crt %s"|format(listener.default_tls_path)|trim() %} +{% else %} +{% set def_crt_opt = "" %} +{% endif %} +{% if listener.crt_dir %} +{% set crt_dir_opt = "crt %s"|format(listener.crt_dir)|trim() %} +{% else %} +{% set crt_dir_opt = "" %} +{% endif %} +bind {{ lb_vip_address }}:{{ listener.protocol_port }} {{ "%s %s"|format(def_crt_opt, crt_dir_opt)|trim() }} +{% endmacro %} + +{% macro use_backend_macro(listener) %} +{% if listener.default_pool %} +default_backend {{ listener.default_pool.id }} +{% endif %} +{% endmacro %} + +{% macro frontend_macro(constants, listener, lb_vip_address) %} +frontend {{ listener.id }} + option tcplog +{% if listener.connection_limit is defined %} + maxconn {{ listener.connection_limit }} +{% endif %} +{% if listener.protocol_mode == constants.PROTOCOL_HTTP.lower() %} + option forwardfor +{% endif %} + {{ bind_macro(constants, listener, lb_vip_address)|trim() }} + mode {{ listener.protocol_mode }} +{% if listener.default_pool %} + default_backend {{ listener.default_pool.id }} +{% endif %} +{% endmacro %} + +{% macro backend_macro(constants, listener, pool) %} +backend {{ pool.id }} + mode {{ pool.protocol }} + balance {{ pool.lb_algorithm }} +{% if listener.protocol == constants.PROTOCOL_TERMINATED_HTTPS %} + redirect scheme https if !{ ssl_fc } +{% endif %} +{% if pool.session_persistence %} +{% if pool.session_persistence.type == constants.SESSION_PERSISTENCE_SOURCE_IP %} + stick-table type ip size 10k + stick on src +{% elif pool.session_persistence.type == constants.SESSION_PERSISTENCE_HTTP_COOKIE %} + cookie SRV insert indirect nocache +{% endif %} +{% endif %} +{% if pool.health_monitor %} + timeout check {{ pool.health_monitor.timeout }} +{% if pool.health_monitor.type == constants.HEALTH_MONITOR_HTTP or pool.health_monitor.type == constants.HEALTH_MONITOR_HTTPS %} + option httpchk {{ pool.health_monitor.http_method }} {{ pool.health_monitor.url_path }} + http-check expect rstatus {{ pool.health_monitor.expected_codes }} +{% endif %} +{% if pool.health_monitor.type == constants.HEALTH_MONITOR_HTTPS %} + option ssl-hello-chk +{% endif %} +{% endif %} +{% if listener.protocol_mode == constants.PROTOCOL_HTTP.lower() %} + option forwardfor +{% endif %} +{% for member in pool.members %} +{% if pool.health_monitor %} +{% set hm_opt = " check inter %ds fall %d"|format(pool.health_monitor.delay, pool.health_monitor.fall_threshold) %} +{% else %} +{% set hm_opt = "" %} +{% endif %} +{%if pool.session_persistence.type == constants.SESSION_PERSISTENCE_HTTP_COOKIE %} +{% set persistence_opt = " cookie %s"|format(member.id) %} +{% else %} +{% set persistence_opt = "" %} +{% endif %} + {{ "server %s %s:%d weight %s%s%s"|e|format(member.id, member.address, member.protocol_port, member.weight, hm_opt, persistence_opt)|trim() }} +{% endfor %} +{% endmacro %} diff --git a/octavia/common/constants.py b/octavia/common/constants.py index e8d9f310e7..697aefd5d3 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -34,6 +34,7 @@ SUPPORTED_HEALTH_MONITOR_TYPES = (HEALTH_MONITOR_HTTP, HEALTH_MONITOR_HTTPS, PROTOCOL_TCP = 'TCP' PROTOCOL_HTTP = 'HTTP' PROTOCOL_HTTPS = 'HTTPS' +PROTOCOL_TERMINATED_HTTPS = 'TERMINATED_HTTPS' SUPPORTED_PROTOCOLS = (PROTOCOL_TCP, PROTOCOL_HTTPS, PROTOCOL_HTTP) ACTIVE = 'ACTIVE' diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py index 61d33bd668..b6e298378b 100644 --- a/octavia/common/data_models.py +++ b/octavia/common/data_models.py @@ -190,6 +190,18 @@ class SNI(BaseDataModel): self.tls_container_id = tls_container_id +class TLSContainer(BaseDataModel): + + def __init__(self, id=None, primary_cn=None, certificate=None, + private_key=None, passphrase=None, intermediates=[]): + self.id = id + self.primary_cn = primary_cn + self.certificate = certificate + self.private_key = private_key + self.passphrase = passphrase + self.intermediates = intermediates + + class Amphora(BaseDataModel): def __init__(self, id=None, load_balancer_id=None, compute_id=None, diff --git a/octavia/tests/unit/amphorae/drivers/haproxy/__init__.py b/octavia/tests/unit/amphorae/drivers/haproxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/octavia/tests/unit/amphorae/drivers/haproxy/jinja/__init__.py b/octavia/tests/unit/amphorae/drivers/haproxy/jinja/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/octavia/tests/unit/amphorae/drivers/haproxy/jinja/test_jinja_cfg.py b/octavia/tests/unit/amphorae/drivers/haproxy/jinja/test_jinja_cfg.py new file mode 100644 index 0000000000..15f093467e --- /dev/null +++ b/octavia/tests/unit/amphorae/drivers/haproxy/jinja/test_jinja_cfg.py @@ -0,0 +1,312 @@ +# Copyright 2014 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from octavia.amphorae.drivers.haproxy.jinja import jinja_cfg +from octavia.tests.unit import base as base +from octavia.tests.unit.common.sample_configs import sample_configs + + +class TestHaproxyCfg(base.TestCase): + def setUp(self): + super(TestHaproxyCfg, self).setUp() + self.jinja_cfg = jinja_cfg.JinjaTemplater( + base_amp_path='/var/lib/octavia', + base_crt_dir='/listeners') + + def test_get_template(self): + template = self.jinja_cfg._get_template() + self.assertEqual('haproxy_listener.template', template.name) + + def test_render_template_tls(self): + fe = ("frontend sample_listener_id_1\n" + " option tcplog\n" + " maxconn 98\n" + " option forwardfor\n" + " bind 10.0.0.2:443 " + "ssl crt /var/lib/octavia/listeners/" + "sample_listener_id_1/FakeCN.pem " + "crt /var/lib/octavia/listeners/sample_listener_id_1\n" + " mode http\n" + " default_backend sample_pool_id_1\n\n") + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " redirect scheme https if !{ ssl_fc }\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " option forwardfor\n" + " server sample_member_id_1 10.0.0.99:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_2\n\n") + tls_tupe = sample_configs.sample_tls_container_tuple( + certificate='imaCert1', private_key='imaPrivateKey1', + primary_cn='FakeCN') + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple(proto='TERMINATED_HTTPS', + tls=True, sni=True), + tls_tupe) + self.assertEqual( + sample_configs.sample_base_expected_config( + frontend=fe, backend=be), + rendered_obj) + + def test_render_template_tls_no_sni(self): + fe = ("frontend sample_listener_id_1\n" + " option tcplog\n" + " maxconn 98\n" + " option forwardfor\n" + " bind 10.0.0.2:443 " + "ssl crt /var/lib/octavia/listeners/" + "sample_listener_id_1/FakeCN.pem\n" + " mode http\n" + " default_backend sample_pool_id_1\n\n") + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " redirect scheme https if !{ ssl_fc }\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " option forwardfor\n" + " server sample_member_id_1 10.0.0.99:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_2\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple( + proto='TERMINATED_HTTPS', tls=True), + tls_cert=sample_configs.sample_tls_container_tuple( + certificate='ImAalsdkfjCert', + private_key='ImAsdlfksdjPrivateKey', + primary_cn="FakeCN")) + self.assertEqual( + sample_configs.sample_base_expected_config( + frontend=fe, backend=be), + rendered_obj) + + def test_render_template_http(self): + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " option forwardfor\n" + " server sample_member_id_1 10.0.0.99:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_2\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple()) + self.assertEqual( + sample_configs.sample_base_expected_config(backend=be), + rendered_obj) + + def test_render_template_https(self): + fe = ("frontend sample_listener_id_1\n" + " option tcplog\n" + " maxconn 98\n" + " bind 10.0.0.2:443\n" + " mode tcp\n" + " default_backend sample_pool_id_1\n\n") + be = ("backend sample_pool_id_1\n" + " mode tcp\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " option ssl-hello-chk\n" + " server sample_member_id_1 10.0.0.99:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 " + "weight 13 check inter 30s fall 3 cookie sample_member_id_2\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple(proto='HTTPS')) + self.assertEqual(sample_configs.sample_base_expected_config( + frontend=fe, backend=be), rendered_obj) + + def test_render_template_no_monitor_http(self): + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " option forwardfor\n" + " server sample_member_id_1 10.0.0.99:82 weight 13 " + "cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 weight 13 " + "cookie sample_member_id_2\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple(proto='HTTP', monitor=False)) + self.assertEqual(sample_configs.sample_base_expected_config( + backend=be), rendered_obj) + + def test_render_template_no_monitor_https(self): + fe = ("frontend sample_listener_id_1\n" + " option tcplog\n" + " maxconn 98\n" + " bind 10.0.0.2:443\n" + " mode tcp\n" + " default_backend sample_pool_id_1\n\n") + be = ("backend sample_pool_id_1\n" + " mode tcp\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " server sample_member_id_1 10.0.0.99:82 weight 13 " + "cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 weight 13 " + "cookie sample_member_id_2\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple(proto='HTTPS', monitor=False)) + self.assertEqual(sample_configs.sample_base_expected_config( + frontend=fe, backend=be), rendered_obj) + + def test_render_template_no_persistence_https(self): + fe = ("frontend sample_listener_id_1\n" + " option tcplog\n" + " maxconn 98\n" + " bind 10.0.0.2:443\n" + " mode tcp\n" + " default_backend sample_pool_id_1\n\n") + be = ("backend sample_pool_id_1\n" + " mode tcp\n" + " balance roundrobin\n" + " server sample_member_id_1 10.0.0.99:82 weight 13\n" + " server sample_member_id_2 10.0.0.98:82 weight 13\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple(proto='HTTPS', monitor=False, + persistence=False)) + self.assertEqual(sample_configs.sample_base_expected_config( + frontend=fe, backend=be), rendered_obj) + + def test_render_template_no_persistence_http(self): + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " option forwardfor\n" + " server sample_member_id_1 10.0.0.99:82 weight 13\n" + " server sample_member_id_2 10.0.0.98:82 weight 13\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple(proto='HTTP', monitor=False, + persistence=False)) + self.assertEqual(sample_configs.sample_base_expected_config( + backend=be), rendered_obj) + + def test_render_template_sourceip_persistence(self): + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " stick-table type ip size 10k\n" + " stick on src\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " option forwardfor\n" + " server sample_member_id_1 10.0.0.99:82 " + "weight 13 check inter 30s fall 3\n" + " server sample_member_id_2 10.0.0.98:82 " + "weight 13 check inter 30s fall 3\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple( + persistence_type='SOURCE_IP')) + self.assertEqual( + sample_configs.sample_base_expected_config(backend=be), + rendered_obj) + + def test_transform_session_persistence(self): + in_persistence = sample_configs.sample_session_persistence_tuple() + ret = self.jinja_cfg._transform_session_persistence(in_persistence) + self.assertEqual(sample_configs.RET_PERSISTENCE, ret) + + def test_transform_health_monitor(self): + in_persistence = sample_configs.sample_health_monitor_tuple() + ret = self.jinja_cfg._transform_health_monitor(in_persistence) + self.assertEqual(sample_configs.RET_MONITOR, ret) + + def test_transform_member(self): + in_member = sample_configs.sample_member_tuple('sample_member_id_1', + '10.0.0.99') + ret = self.jinja_cfg._transform_member(in_member) + self.assertEqual(sample_configs.RET_MEMBER_1, ret) + + def test_transform_pool(self): + in_pool = sample_configs.sample_pool_tuple() + ret = self.jinja_cfg._transform_pool(in_pool) + self.assertEqual(sample_configs.RET_POOL, ret) + + def test_transform_listener(self): + in_listener = sample_configs.sample_listener_tuple() + ret = self.jinja_cfg._transform_listener(in_listener, None) + self.assertEqual(sample_configs.RET_LISTENER, ret) + + def test_transform_loadbalancer(self): + in_listener = sample_configs.sample_listener_tuple() + ret = self.jinja_cfg._transform_loadbalancer( + in_listener.loadbalancer, in_listener, None) + self.assertEqual(sample_configs.RET_LB, ret) + + def test_include_member(self): + ret = self.jinja_cfg._include_member( + sample_configs.sample_member_tuple('sample_member_id_1', + '10.0.0.99')) + self.assertTrue(ret) + + def test_include_member_invalid_status(self): + ret = self.jinja_cfg._include_member( + sample_configs.sample_member_tuple('sample_member_id_1', + '10.0.0.99', + operating_status='PENDING')) + self.assertFalse(ret) + + def test_include_member_invalid_admin_state(self): + ret = self.jinja_cfg._include_member( + sample_configs.sample_member_tuple('sample_member_id_1', + '10.0.0.99', + enabled=False)) + self.assertFalse(ret) + + def test_expand_expected_codes(self): + exp_codes = '' + self.assertEqual(self.jinja_cfg._expand_expected_codes(exp_codes), + set([])) + exp_codes = '200' + self.assertEqual( + self.jinja_cfg._expand_expected_codes(exp_codes), set(['200'])) + exp_codes = '200, 201' + self.assertEqual(self.jinja_cfg._expand_expected_codes(exp_codes), + set(['200', '201'])) + exp_codes = '200, 201,202' + self.assertEqual(self.jinja_cfg._expand_expected_codes(exp_codes), + set(['200', '201', '202'])) + exp_codes = '200-202' + self.assertEqual(self.jinja_cfg._expand_expected_codes(exp_codes), + set(['200', '201', '202'])) + exp_codes = '200-202, 205' + self.assertEqual(self.jinja_cfg._expand_expected_codes(exp_codes), + set(['200', '201', '202', '205'])) + exp_codes = '200, 201-203' + self.assertEqual(self.jinja_cfg._expand_expected_codes(exp_codes), + set(['200', '201', '202', '203'])) + exp_codes = '200, 201-203, 205' + self.assertEqual(self.jinja_cfg._expand_expected_codes(exp_codes), + set(['200', '201', '202', '203', '205'])) + exp_codes = '201-200, 205' + self.assertEqual( + self.jinja_cfg._expand_expected_codes(exp_codes), set(['205'])) diff --git a/octavia/tests/unit/common/sample_configs/__init__.py b/octavia/tests/unit/common/sample_configs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/octavia/tests/unit/common/sample_configs/sample_configs.py b/octavia/tests/unit/common/sample_configs/sample_configs.py new file mode 100644 index 0000000000..f0b74f31bd --- /dev/null +++ b/octavia/tests/unit/common/sample_configs/sample_configs.py @@ -0,0 +1,308 @@ +# Copyright 2014 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import collections + +RET_PERSISTENCE = { + 'type': 'HTTP_COOKIE', + 'cookie_name': 'HTTP_COOKIE'} + +RET_MONITOR = { + 'id': 'sample_monitor_id_1', + 'type': 'HTTP', + 'delay': 30, + 'timeout': 31, + 'fall_threshold': 3, + 'http_method': 'GET', + 'url_path': '/index.html', + 'expected_codes': '418', + 'enabled': True} + +RET_MEMBER_1 = { + 'id': 'sample_member_id_1', + 'address': '10.0.0.99', + 'protocol_port': 82, + 'weight': 13, + 'subnet_id': '10.0.0.1/24', + 'enabled': True, + 'operating_status': 'ACTIVE'} + +RET_MEMBER_2 = { + 'id': 'sample_member_id_2', + 'address': '10.0.0.98', + 'protocol_port': 82, + 'weight': 13, + 'subnet_id': '10.0.0.1/24', + 'enabled': True, + 'operating_status': 'ACTIVE'} + +RET_POOL = { + 'id': 'sample_pool_id_1', + 'protocol': 'http', + 'lb_algorithm': 'roundrobin', + 'members': [RET_MEMBER_1, RET_MEMBER_2], + 'health_monitor': RET_MONITOR, + 'session_persistence': RET_PERSISTENCE, + 'enabled': True, + 'operating_status': 'ACTIVE'} + +RET_DEF_TLS_CONT = {'id': 'cont_id_1', 'allencompassingpem': 'imapem', + 'primary_cn': 'FakeCn'} +RET_SNI_CONT_1 = {'id': 'cont_id_2', 'allencompassingpem': 'imapem2', + 'primary_cn': 'FakeCn'} +RET_SNI_CONT_2 = {'id': 'cont_id_3', 'allencompassingpem': 'imapem3', + 'primary_cn': 'FakeCn2'} + +RET_LISTENER = { + 'id': 'sample_listener_id_1', + 'protocol_port': '80', + 'protocol': 'HTTP', + 'protocol_mode': 'http', + 'default_pool': RET_POOL, + 'connection_limit': 98} + +RET_LISTENER_TLS = { + 'id': 'sample_listener_id_1', + 'protocol_port': '443', + 'protocol': 'TERMINATED_HTTPS', + 'protocol_mode': 'http', + 'default_pool': RET_POOL, + 'connection_limit': 98, + 'tls_container_id': 'cont_id_1', + 'default_tls_path': '/etc/ssl/sample_loadbalancer_id_1/fakeCN.pem', + 'default_tls_container': RET_DEF_TLS_CONT} + +RET_LISTENER_TLS_SNI = { + 'id': 'sample_listener_id_1', + 'protocol_port': '443', + 'protocol': 'http', + 'protocol': 'TERMINATED_HTTPS', + 'default_pool': RET_POOL, + 'connection_limit': 98, + 'tls_container_id': 'cont_id_1', + 'default_tls_path': '/etc/ssl/sample_loadbalancer_id_1/fakeCN.pem', + 'default_tls_container': RET_DEF_TLS_CONT, + 'crt_dir': '/v2/sample_loadbalancer_id_1', + 'sni_container_ids': ['cont_id_2', 'cont_id_3'], + 'sni_containers': [RET_SNI_CONT_1, RET_SNI_CONT_2]} + +RET_LB = { + 'name': 'test-lb', + 'vip_address': '10.0.0.2', + 'listener': RET_LISTENER} + +RET_LB_TLS = { + 'name': 'test-lb', + 'vip_address': '10.0.0.2', + 'listener': RET_LISTENER_TLS} + +RET_LB_TLS_SNI = { + 'name': 'test-lb', + 'vip_address': '10.0.0.2', + 'listener': RET_LISTENER_TLS_SNI} + + +def sample_loadbalancer_tuple(proto=None, monitor=True, persistence=True, + persistence_type=None, tls=False, sni=False): + proto = 'HTTP' if proto is None else proto + in_lb = collections.namedtuple( + 'loadbalancer', 'id, name, protocol, vip, ' + 'listeners') + return in_lb( + id='sample_loadbalancer_id_1', + name='test-lb', + protocol=proto, + vip=sample_vip_tuple(), + listeners=[sample_listener_tuple(proto=proto, monitor=monitor, + persistence=persistence, + persistence_type=persistence_type, + tls=tls, + sni=sni)] + ) + + +def sample_listener_loadbalancer_tuple(proto=None): + proto = 'HTTP' if proto is None else proto + in_lb = collections.namedtuple( + 'loadbalancer', 'id, name, protocol, vip') + return in_lb( + id='sample_loadbalancer_id_1', + name='test-lb', + protocol=proto, + vip=sample_vip_tuple() + ) + + +def sample_vip_tuple(): + vip = collections.namedtuple('vip', 'ip_address') + return vip(ip_address='10.0.0.2') + + +def sample_listener_tuple(proto=None, monitor=True, persistence=True, + persistence_type=None, tls=False, sni=False): + proto = 'HTTP' if proto is None else proto + port = '443' if proto is 'HTTPS' or proto is 'TERMINATED_HTTPS' else '80' + in_listener = collections.namedtuple( + 'listener', 'id, protocol_port, protocol, default_pool, ' + 'connection_limit, tls_container_id, ' + 'sni_container_ids, default_tls_container, ' + 'sni_containers, loadbalancer') + return in_listener( + id='sample_listener_id_1', + protocol_port=port, + protocol=proto, + loadbalancer=sample_listener_loadbalancer_tuple(proto=proto), + default_pool=sample_pool_tuple( + proto=proto, monitor=monitor, persistence=persistence, + persistence_type=persistence_type), + connection_limit=98, + tls_container_id='cont_id_1' if tls else '', + sni_container_ids=['cont_id_2', 'cont_id_3'] if sni else [], + default_tls_container=sample_tls_container_tuple( + id='cont_id_1', certificate='--imapem1--\n', + private_key='--imakey1--\n', intermediates=[ + '--imainter1--\n', '--imainter1too--\n'], + primary_cn='aFakeCN' + ) if tls else '', + sni_containers=[ + sample_tls_sni_container_tuple( + tls_container=sample_tls_container_tuple( + id='cont_id_2', certificate='--imapem2--\n', + private_key='--imakey2--\n', intermediates=[ + '--imainter2--\n', '--imainter2too--\n' + ], primary_cn='aFakeCN')), + sample_tls_sni_container_tuple( + tls_container=sample_tls_container_tuple( + id='cont_id_3', certificate='--imapem3--\n', + private_key='--imakey3--\n', intermediates=[ + '--imainter3--\n', '--imainter3too--\n' + ], primary_cn='aFakeCN'))] + if sni else [] + ) + + +def sample_tls_sni_container_tuple(tls_container=None): + sc = collections.namedtuple('sni_container', 'tls_container') + return sc(tls_container=tls_container) + + +def sample_tls_sni_containers_tuple(tls_container=None): + sc = collections.namedtuple('sni_containers', 'tls_container') + return [sc(tls_container=tls_container)] + + +def sample_tls_container_tuple(id='cont_id_1', certificate=None, + private_key=None, intermediates=[], + primary_cn=None): + sc = collections.namedtuple( + 'tls_container', + 'id, certificate, private_key, intermediates, primary_cn') + return sc(id=id, certificate=certificate, private_key=private_key, + intermediates=intermediates, primary_cn=primary_cn) + + +def sample_pool_tuple(proto=None, monitor=True, persistence=True, + persistence_type=None): + proto = 'HTTP' if proto is None else proto + in_pool = collections.namedtuple( + 'pool', 'id, protocol, lb_algorithm, members, healthmonitor,' + 'sessionpersistence, enabled, operating_status') + mon = sample_health_monitor_tuple(proto=proto) if monitor is True else None + persis = sample_session_persistence_tuple( + persistence_type=persistence_type) if persistence is True else None + return in_pool( + id='sample_pool_id_1', + protocol=proto, + lb_algorithm='ROUND_ROBIN', + members=[sample_member_tuple('sample_member_id_1', '10.0.0.99'), + sample_member_tuple('sample_member_id_2', '10.0.0.98')], + healthmonitor=mon, + sessionpersistence=persis, + enabled=True, + operating_status='ACTIVE') + + +def sample_member_tuple(id, ip, enabled=True, operating_status='ACTIVE'): + in_member = collections.namedtuple('member', + 'id, ip_address, protocol_port, ' + 'weight, subnet_id, ' + 'enabled, operating_status') + return in_member( + id=id, + ip_address=ip, + protocol_port=82, + weight=13, + subnet_id='10.0.0.1/24', + enabled=enabled, + operating_status=operating_status) + + +def sample_session_persistence_tuple(persistence_type=None): + spersistence = collections.namedtuple('SessionPersistence', + 'type, cookie_name') + pt = 'HTTP_COOKIE' if persistence_type is None else persistence_type + return spersistence(type=pt, + cookie_name=pt) + + +def sample_health_monitor_tuple(proto='HTTP'): + proto = 'HTTP' if proto is 'TERMINATED_HTTPS' else proto + monitor = collections.namedtuple( + 'monitor', 'id, type, delay, timeout, fall_threshold, http_method, ' + 'url_path, expected_codes, enabled') + + return monitor(id='sample_monitor_id_1', type=proto, delay=30, + timeout=31, fall_threshold=3, http_method='GET', + url_path='/index.html', expected_codes='418', + enabled=True) + + +def sample_base_expected_config(frontend=None, backend=None): + if frontend is None: + frontend = ("frontend sample_listener_id_1\n" + " option tcplog\n" + " maxconn 98\n" + " option forwardfor\n" + " bind 10.0.0.2:80\n" + " mode http\n" + " default_backend sample_pool_id_1\n\n") + if backend is None: + backend = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " server sample_member_id_1 10.0.0.99:82 weight 13 " + "check inter 30s fall 3 cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 weight 13 " + "check inter 30s fall 3 cookie sample_member_id_2\n") + return ("# Configuration for test-lb\n" + "global\n" + " daemon\n" + " user nobody\n" + " group nogroup\n" + " log /dev/log local0\n" + " log /dev/log local1 notice\n" + " stats socket /var/lib/octavia/sample_listener_id_1.sock" + " mode 0666 level user\n\n" + "defaults\n" + " log global\n" + " retries 3\n" + " option redispatch\n" + " timeout connect 5000\n" + " timeout client 50000\n" + " timeout server 50000\n\n" + frontend + backend) diff --git a/requirements.txt b/requirements.txt index 6c9beb8f96..076f36d1b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,3 +31,5 @@ pyOpenSSL>=0.11 WSME>=0.6 pyasn1 pyasn1_modules + +jinja2