Skip to content

Commit

Permalink
feature: tcp & udp streams (#6)
Browse files Browse the repository at this point in the history
bazilio91 authored Jan 25, 2017
1 parent 33c3ca9 commit de10a17
Showing 19 changed files with 312 additions and 147 deletions.
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -22,6 +22,8 @@ ADD src /opt/vergilius
RUN cd /opt/vergilius/ && python setup.py install
WORKDIR /opt/vergilius/

EXPOSE 80 443
EXPOSE 80 443 7000-8000

ENV DHPARAM_LENGTH 4096

RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -37,10 +37,16 @@ Consul service config example
}
```

Vergilius looks for registered services with tags `http` and `http2`, creates upstream with all containers of this service,
Vergilius looks for registered services with tags `http` and `http2` creates upstream with all containers of this service,
routes requests from `(www.)?service.example.com` and `*.(www.)?service.example.com` to containers using nginx
`least_conn` balancing algorithm.

You can also add `tcp` and `udp` tags to service, vergilus will stream this protocols too.
External ports for this services are stored in consul KV at `vergilius/ports/%service_name%`.
You can configure external ports range with `PROXY_PORTS` env, for ex.: `5000-6000`.
It's strongly recommended to use vergilius in `net=host` mode or disable `userland-proxy`,
because docker will create as much userland proxies as `PROXY_PORTS` you have.

#### how http2 works

To use `http2` proxy, use `http2` tag instead of `http` or use both. Vergilius will try to acquire certificate from
9 changes: 7 additions & 2 deletions circle.yml
Original file line number Diff line number Diff line change
@@ -20,9 +20,14 @@ test:
pre:
- docker run -d -p 8500:8500 e96tech/consul-server -advertise 127.0.0.1 -bootstrap -dc circle -domain local
- curl --retry 10 --retry-delay 5 -v http://localhost:8500
- sed -i '1i load_module "modules/ngx_stream_module.so";' /home/ubuntu/vergilius/src/vergilius/templates/service_validate.html

deployment:
dockerhub:
dockerhub_master:
branch: master
commands:
- 'curl -H "Content-Type: application/json" --data "{\"source_type\": \"Branch\", \"source_name\": \"master\"}" -X POST https://registry.hub.docker.com/u/devopsftw/vergilius/trigger/ea3f932c-49b9-47e8-af0c-ec1d8615cda4/'
- 'curl -H "Content-Type: application/json" --data "{\"source_type\": \"Branch\", \"source_name\": \"master\"}" -X POST https://registry.hub.docker.com/u/devopsftw/vergilius/trigger/ea3f932c-49b9-47e8-af0c-ec1d8615cda4/'
dockerhub_tag:
tag: /.*/
commands:
- 'curl -H "Content-Type: application/json" --data "{\"source_type\": \"Tag\", \"source_name\": \"$CIRCLE_TAG\"}" -X POST https://registry.hub.docker.com/u/devopsftw/vergilius/trigger/ea3f932c-49b9-47e8-af0c-ec1d8615cda4/'
12 changes: 11 additions & 1 deletion nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -41,6 +41,16 @@ http {

ssl_dhparam /etc/nginx/dhparam/dhparam.pem;

include /etc/nginx/conf.d/*.conf;
include /etc/nginx/conf.d/*.upstream.conf;
include /etc/nginx/conf.d/*.http.conf;
include /etc/nginx/conf.d/*.http2.conf;
include /etc/nginx/conf.d/default.conf;
include /etc/nginx/sites-enabled/*.conf;
}

stream {
include /etc/nginx/conf.d/*.upstream.conf;
include /etc/nginx/conf.d/*.tcp.conf;
include /etc/nginx/conf.d/*.udp.conf;
}

4 changes: 2 additions & 2 deletions services/nginx.sh
Original file line number Diff line number Diff line change
@@ -7,8 +7,8 @@ function die {

if [ ! -f /etc/nginx/dhparam/dhparam.pem ]; then
mkdir -p /etc/nginx/dhparam/
echo "dhparam file /etc/nginx/dhparam/dhparam.pem does not exist. Generating one with 4086 bit. This will take a while..."
openssl dhparam -out /etc/nginx/dhparam/dhparam.pem 4096 || die "Could not generate dhparam file"
echo "dhparam file /etc/nginx/dhparam/dhparam.pem does not exist. Generating one with $DHPARAM_LENGTH bit. This will take a while..."
openssl dhparam -out /etc/nginx/dhparam/dhparam.pem $DHPARAM_LENGTH || die "Could not generate dhparam file"
echo "Finished. Starting nginx now..."
fi

20 changes: 20 additions & 0 deletions src/vergilius/components/port_allocator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from vergilius.config import PROXY_PORTS

allocated = set()


def allocate():
min_port = PROXY_PORTS[0]
max_port = PROXY_PORTS[1]

while min_port < max_port:
if min_port not in allocated:
allocated.add(min_port)
return min_port
min_port += 1

raise Exception('Failed to allocate port')


def release(port):
allocated.discard(int(port))
1 change: 1 addition & 0 deletions src/vergilius/config.py
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@
NGINX_BINARY = os.environ.get('NGINX_BINARY', '/usr/sbin/nginx')
NGINX_HTTP_PORT = os.environ.get('NGINX_HTTP_PORT', 80)
NGINX_HTTP2_PORT = os.environ.get('NGINX_HTTP2_PORT', 443)
PROXY_PORTS = [int(s) for s in os.environ.get('PROXY_PORTS', '7000-8000').split('-')]

ACME_DIRECTORY_URL = os.environ.get('ACME_DIRECTORY_URL', 'https://acme-staging.api.letsencrypt.org/directory')

3 changes: 2 additions & 1 deletion src/vergilius/loop/service_watcher.py
Original file line number Diff line number Diff line change
@@ -23,7 +23,8 @@ def watch_services(self):

def check_services(self, data):
# check if service has any of our tags
services_to_publish = dict((k, v) for k, v in data.items() if any(x in v for x in [u'http', u'http2']))
services_to_publish = dict(
(k, v) for k, v in data.items() if any(x in v for x in [u'http', u'http2', u'tcp', u'udp']))
for service_name in services_to_publish:
if service_name not in self.services:
vergilius.logger.info('[service watcher]: new service: %s' % service_name)
130 changes: 86 additions & 44 deletions src/vergilius/models/service.py
Original file line number Diff line number Diff line change
@@ -4,8 +4,12 @@
import tempfile
import unicodedata

import itertools
from consul import tornado, base, ConsulException
from shutil import rmtree

from vergilius import config, consul_tornado, consul, logger, template_loader
from vergilius.components import port_allocator
from vergilius.loop.nginx_reloader import NginxReloader
from vergilius.models.certificate import Certificate

@@ -20,9 +24,12 @@ def __init__(self, name):
logger.info('[service][%s]: new and loading' % self.name)
self.allow_crossdomain = False
self.nodes = {}
self.domains = {
self.port = None
self.binds = {
u'http': set(),
u'http2': set()
u'http2': set(),
u'tcp': set(),
u'udp': set()
}

self.active = True
@@ -35,15 +42,15 @@ def __init__(self, name):
self.watch()

def fetch(self):
index, data = consul.health.service(self.name, passing=True)
index, data = consul.health.service(self.id, passing=True)
self.parse_data(data)

@tornado.gen.coroutine
def watch(self):
index = None
while True and self.active:
try:
index, data = yield consul_tornado.health.service(self.name, index, wait=None, passing=True)
index, data = yield consul_tornado.health.service(self.id, index, wait=None, passing=True)
self.parse_data(data)
except ConsulException as e:
logger.error('consul exception: %s' % e)
@@ -55,18 +62,18 @@ def parse_data(self, data):
:type data: set[]
"""
for protocol in self.domains.iterkeys():
self.domains[protocol].clear()
for protocol in self.binds.iterkeys():
self.binds[protocol].clear()

allow_crossdomain = False
self.nodes = {}
for node in data:
if not node[u'Service'][u'Port']:
logger.warn('[service][%s]: Node %s is ignored due no ServicePort' % (self.id, node[u'Node']))
logger.warn('[service][%s]: Node %s is ignored due no Service Port' % (self.id, node[u'Node'][u'Node']))
continue

if node[u'Service'][u'Tags'] is None:
logger.warn('[service][%s]: Node %s is ignored due no ServiceTags' % (self.id, node[u'Node']))
logger.warn('[service][%s]: Node %s is ignored due no Service Tags' % (self.id, node[u'Node'][u'Node']))
continue

self.nodes[node['Node']['Node']] = {
@@ -80,76 +87,97 @@ def parse_data(self, data):

for protocol in [u'http', u'http2']:
if protocol in node[u'Service'][u'Tags']:
self.domains[protocol].update(
self.binds[protocol].update(
tag.replace(protocol + ':', '') for tag in node[u'Service'][u'Tags'] if
tag.startswith(protocol + ':')
)

for protocol in ['tcp', 'udp']:
self.binds[protocol].update({node[u'Service'][u'Port']})

self.allow_crossdomain = allow_crossdomain

self.flush_nginx_config()

def get_nginx_config(self):
def get_nginx_config(self, config_type):
"""
Generate nginx config from service attributes
:param config_type: string
"""
if self.domains[u'http2']:
if config_type == 'http2' and len(self.binds['http2']):
self.check_certificate()
return template_loader.load('service.html').generate(service=self, config=config)

if config_type in ['tcp', 'udp']:
self.check_port()

return template_loader.load('service_%s.html' % config_type).generate(service=self, config=config)

def flush_nginx_config(self):
if not self.validate():
logger.error('[service][%s]: failed to validate nginx config!' % self.id)
return False

nginx_config = self.get_nginx_config()
deployed_nginx_config = None
has_changes = False

try:
deployed_nginx_config = self.read_nginx_config_file()
except IOError:
pass
for config_type in self.get_config_types():
nginx_config = self.get_nginx_config(config_type)
deployed_nginx_config = None

if deployed_nginx_config != nginx_config:
config_file = open(self.get_nginx_config_path(), 'w+')
config_file.write(nginx_config)
config_file.close()
logger.info('[service][%s]: got new nginx config %s' % (self.name, self.get_nginx_config_path()))
try:
deployed_nginx_config = self.read_nginx_config_file(config_type)
except IOError:
pass

if deployed_nginx_config != nginx_config:
config_file = open(self.get_nginx_config_path(config_type), 'w+')
config_file.write(nginx_config)
config_file.close()
has_changes = True

if has_changes:
NginxReloader.queue_reload()
logger.info('[service][%s]: got new nginx config' % self.name)

def get_nginx_config_path(self):
return os.path.join(config.NGINX_CONFIG_PATH, self.id + '.conf')
def get_nginx_config_path(self, config_type):
return os.path.join(config.NGINX_CONFIG_PATH, '%s.%s.conf' % (self.id, config_type))

def read_nginx_config_file(self):
with open(self.get_nginx_config_path(), 'r') as config_file:
def read_nginx_config_file(self, config_type):
with open(self.get_nginx_config_path(config_type), 'r') as config_file:
config_content = config_file.read()
config_file.close()
return config_content

def get_config_types(self):
return itertools.chain(self.binds.keys(), ['upstream'])

def validate(self):
"""
Deploy temporary service & nginx config and validate it with nginx
:return: bool
"""
service_config_file = tempfile.NamedTemporaryFile(delete=False)
service_config_file.write(self.get_nginx_config())
service_config_file.close()

nginx_config_file = tempfile.NamedTemporaryFile(delete=False)
nginx_config_file.write(template_loader.load('service_validate.html')
.generate(service_config=service_config_file.name,
pid_file='%s.pid' % service_config_file.name)
)

temp_dir = tempfile.mkdtemp()

files = {}
for config_type in self.get_config_types():
path = os.path.join(temp_dir, config_type)
config_file = open(path, 'w+')
config_file.write(self.get_nginx_config(config_type))
config_file.close()
files['service_%s' % config_type] = path

files['pid_file'] = os.path.join(temp_dir, 'pid')

nginx_config_file = open(os.path.join(temp_dir, 'service'), 'w+')
nginx_config_file.write(template_loader.load('service_validate.html').generate(**files))
nginx_config_file.close()

try:
return_code = subprocess.check_call([config.NGINX_BINARY, '-t', '-c', nginx_config_file.name])
except subprocess.CalledProcessError:
return_code = 1
finally:
os.unlink(service_config_file.name)
os.unlink('%s.pid' % service_config_file.name)
os.unlink(nginx_config_file.name)
rmtree(temp_dir, ignore_errors=True)

return return_code == 0

@@ -161,10 +189,14 @@ def delete(self):
logger.info('[service][%s]: deleting' % self.name)
self.active = False

try:
os.remove(self.get_nginx_config_path())
except OSError:
pass
if self.port:
self.release_port()

for config_type in self.get_config_types():
try:
os.remove(self.get_nginx_config_path(config_type))
except OSError:
pass

def __del__(self):
if self.active:
@@ -182,4 +214,14 @@ def slugify(cls, string):

def check_certificate(self):
if not self.certificate:
self.certificate = Certificate(service=self, domains=self.domains[u'http2'])
self.certificate = Certificate(service=self, domains=self.binds['http2'])

def check_port(self):
if not self.port:
self.port = port_allocator.allocate()
consul.kv.put('vergilius/ports/%s' % self.name, str(self.port))

def release_port(self):
if self.port:
port_allocator.release(self.port)
consul.kv.delete('vergilius/ports/%s' % self.name)
72 changes: 0 additions & 72 deletions src/vergilius/templates/service.html

This file was deleted.

35 changes: 35 additions & 0 deletions src/vergilius/templates/service_http.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% whitespace all%}
{% if len(service.binds['http']) %}
server {
server_name{% for domain in service.binds['http'] %} {{ domain }} *.{{ domain }}{% end %};
listen {{config.NGINX_HTTP_PORT}};

{% if len(service.binds['http']) %}
location / {
proxy_pass http://{{service.id}};

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

{% if service.allow_crossdomain %}
proxy_hide_header 'Access-Control-Allow-Origin';
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cookie,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With';

if ($request_method = 'OPTIONS' ) {
# if request method is options we immediately return with 200 OK.
return 200;
}
{% end %}
}
{% else %}
location / {
return 301 https://$server_name$request_uri;
}
{% end %}
}
{% end %}
31 changes: 31 additions & 0 deletions src/vergilius/templates/service_http2.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% whitespace all%}
{% if len(service.binds['http2']) and service.certificate.private_key and service.certificate.public_key %}
server {
server_name{% for domain in service.binds['http2'] %} {{ domain }} *.{{ domain }}{% end %};
listen {{config.NGINX_HTTP2_PORT}} ssl http2;

ssl_certificate {{service.certificate.get_cert_path()}};
ssl_certificate_key {{service.certificate.get_key_path()}};

location / {
proxy_pass http://{{service.id}};

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
{% if service.allow_crossdomain %}
proxy_hide_header 'Access-Control-Allow-Origin';
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cookie,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With';

if ($request_method = 'OPTIONS' ) {
# if request method is options we immediately return with 200 OK.
return 200;
}
{% end %}
}
}
{% end %}
7 changes: 7 additions & 0 deletions src/vergilius/templates/service_tcp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% whitespace all%}
{% if len(service.binds['tcp']) %}
server {
listen {{service.port}};
proxy_pass {{service.id}};
}
{% end %}
7 changes: 7 additions & 0 deletions src/vergilius/templates/service_udp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% whitespace all%}
{% if len(service.binds['udp']) %}
server {
listen {{service.port}} udp;
proxy_pass {{service.id}};
}
{% end %}
8 changes: 8 additions & 0 deletions src/vergilius/templates/service_upstream.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% whitespace all%}
upstream {{service.id}} {
hash $remote_addr consistent;

{% for node_name in service.nodes %}server {{ service.nodes[node_name]['address'] }}:{{ service.nodes[node_name]['port'] }};
{% end %}
{% if not service.nodes %}server 127.0.0.1:6666;{% end %}
}
9 changes: 8 additions & 1 deletion src/vergilius/templates/service_validate.html
Original file line number Diff line number Diff line change
@@ -8,6 +8,13 @@


http {
include {{service_config}};
include {{service_upstream}};
include {{service_http}};
include {{service_http2}};
}

stream {
include {{service_upstream}};
include {{service_tcp}};
include {{service_udp}};
}
23 changes: 20 additions & 3 deletions tests/base_test.py
Original file line number Diff line number Diff line change
@@ -9,13 +9,21 @@

import vergilius
from vergilius import consul, logger
from vergilius.components import port_allocator
from vergilius.loop.service_watcher import ServiceWatcher
from vergilius.models.identity import Identity

# for requests headers:
# import httplib as http_client
# http_client.HTTPConnection.debuglevel = 1

out_hdlr = logging.StreamHandler(sys.stdout)
out_hdlr.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
out_hdlr.setLevel(logging.DEBUG)

requests_log = logging.getLogger("requests.packages.urllib3")
requests_log.setLevel(logging.DEBUG)
requests_log.propagate = True

logger.addHandler(out_hdlr)
logger.setLevel(logging.DEBUG)

@@ -44,7 +52,12 @@ def setUp(self):

try:
os.mkdir(vergilius.config.DATA_PATH)
except OSError as e:
print(e)

try:
os.mkdir(vergilius.config.NGINX_CONFIG_PATH)
os.mkdir(os.path.join(vergilius.config.NGINX_CONFIG_PATH, 'certs'))
except OSError as e:
print(e)

@@ -53,6 +66,10 @@ def setUp(self):
def tearDown(self):
super(BaseTest, self).tearDown()
consul.kv.delete('vergilius', True)
port_allocator.allocated = set()

shutil.rmtree(vergilius.config.NGINX_CONFIG_PATH)
shutil.rmtree(vergilius.config.DATA_PATH)
try:
shutil.rmtree(vergilius.config.NGINX_CONFIG_PATH)
shutil.rmtree(vergilius.config.DATA_PATH)
except OSError as e:
print(e)
9 changes: 5 additions & 4 deletions tests/test_certificate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from mock import mock

from base_test import BaseTest
from vergilius import consul
from vergilius import consul, DummyCertificateProvider
from vergilius.models.certificate import Certificate
from vergilius.models.service import Service

@@ -10,6 +10,7 @@ class Test(BaseTest):
def __init__(self, methodName='runTest'):
super(Test, self).__init__(methodName)
self.service = Service('test')
self.service.binds['http2'] = {'example.com'}

def setUp(self):
super(Test, self).setUp()
@@ -19,6 +20,6 @@ def test_keys_request(self):
cert = Certificate(service=self.service, domains={'example.com'})
self.assertTrue(cert.validate(), 'got valid keys')

with mock.patch.object(Certificate, 'request_certificate', return_value={}) as mock_method:
Certificate(service=self.service, domains={'example.com'})
self.assertFalse(mock_method.called, 'check if existing keys are not requested from provider')
with mock.patch.object(DummyCertificateProvider, 'get_certificate', return_value={}) as mock_method:
Certificate(service=self.service, domains={'example.com'})
self.assertFalse(mock_method.called, 'existing keys are not requested from provider')
67 changes: 52 additions & 15 deletions tests/test_service.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
from mock import mock

from base_test import BaseTest
from vergilius import consul
from vergilius.components import port_allocator
from vergilius.models.service import Service


class Test(BaseTest):
def setUp(self):
super(Test, self).setUp()
consul.kv.delete('vergilius', True)

def test_watcher(self):
pass

def test_base(self):
service = Service(name='test service')
service.flush_nginx_config()

config_file = service.get_nginx_config_path()
self.assertNotEqual(service.read_nginx_config_file().find('server 127.0.0.1:6666'), -1,
'config written and has backup 503')
config_file = service.get_nginx_config_path('upstream')
self.assertNotEqual(
service.read_nginx_config_file('upstream').find('server 127.0.0.1:6666'),
-1, 'config written and has backup 503')
self.assertTrue(service.validate(), 'nginx config is valid')
service.delete()

@@ -27,24 +26,62 @@ def test_base(self):

def test_http(self):
service = Service(name='test service')
service.binds['http'] = {'example.com'}

service.domains[u'http'] = ('example.com',)

self.assertNotEqual(service.get_nginx_config().find('server_name example.com *.example.com;'), -1,
'server_name and wildcard present')
self.assertNotEqual(
service.get_nginx_config('http').find('server_name example.com *.example.com;'), -1,
'server_name and wildcard present')
self.assertTrue(service.validate(), 'nginx config is valid')

def test_http2(self):
service = Service(name='test service')
service.domains[u'http2'] = ('example.com',)
service.binds['http2'] = {'example.com'}

self.assertTrue(service.validate(), 'nginx config is valid')

def test_upstream_nodes(self):
@mock.patch.object(Service, 'watch')
def test_upstream_nodes(self, _):
service = Service(name='test service')
service.domains[u'http'] = ('example.com',)

service.binds['http'] = {'example.com'}
service.nodes['test_node'] = {'address': '127.0.0.1', 'port': '10000'}

self.assertTrue(service.validate(), 'nginx config is valid')
config = service.get_nginx_config('upstream')

config = service.get_nginx_config()
self.assertNotEqual(config.find('server 127.0.0.1:10000;'), -1, 'upstream node present')
self.assertEqual(config.find('server 127.0.0.1:6666'), -1, 'backup node deleted')

@mock.patch.object(Service, 'watch')
def test_tcp(self, _):
service = Service(name='test service')
service.binds['tcp'] = {'10000'}
service.nodes['test_node'] = {'address': '127.0.0.1', 'port': '10000'}

self.assertTrue(service.validate(), 'nginx config is valid')
config = service.get_nginx_config('tcp')
self.assertNotEqual(config.find('listen %s;' % service.port), -1, 'tcp listen valid')

@mock.patch.object(Service, 'watch')
def test_udp(self, _):
service = Service(name='test service')
service.binds['udp'] = {'10000'}
service.nodes['test_node'] = {'address': '127.0.0.1', 'port': '10000'}

self.assertTrue(service.validate(), 'nginx config is valid')
config = service.get_nginx_config('udp')
self.assertNotEqual(config.find('listen %s udp;' % service.port), -1, 'udp listen valid')

def test_port_allocate(self):
service = Service(name='test service')

service.check_port()
self.assertEqual(service.port, 7000)
consul_port_data = consul.kv.get('vergilius/ports/test service')
self.assertIsNotNone(consul_port_data)
self.assertEqual('7000', consul_port_data[1][u'Value'])

service.delete()
self.assertFalse(7000 in port_allocator.allocated)
consul_port_data = consul.kv.get('vergilius/ports/test service')
self.assertIsNotNone(consul_port_data)

0 comments on commit de10a17

Please sign in to comment.