mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
Acceptance tests with behave
This commit is contained in:
+4
-3
@@ -3,7 +3,7 @@ language: python
|
||||
addons:
|
||||
postgresql: "9.5"
|
||||
env:
|
||||
- BOTO_CONFIG=''
|
||||
- BOTO_CONFIG='' ETCDVERSION=2.2.5
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
@@ -19,11 +19,12 @@ install:
|
||||
- sudo /etc/init.d/postgresql stop
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install -r requirements-py2.txt --use-mirrors; fi
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -r requirements-py3.txt; fi
|
||||
- pip install coveralls codacy-coverage
|
||||
- curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C . --strip=1 --wildcards --no-anchored etcd
|
||||
- pip install behave coveralls codacy-coverage
|
||||
script:
|
||||
- python setup.py test
|
||||
- python setup.py flake8
|
||||
- bash -x acceptance_tests.sh
|
||||
- PATH=.:$PATH behave
|
||||
after_success:
|
||||
- coveralls
|
||||
- python-codacy-coverage -r coverage.xml
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
ETCDVERSION=2.2.5
|
||||
|
||||
BINDIR=bin
|
||||
[ -d $BINDIR ] || mkdir $BINDIR
|
||||
|
||||
export PATH=$BINDIR:$PATH
|
||||
|
||||
# Add etcd
|
||||
curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C $BINDIR --strip=1 --wildcards --no-anchored etcd etcdctl
|
||||
|
||||
sudo pip2.7 install lettuce python-Levenshtein
|
||||
sudo pip2.7 install -r requirements-py2.txt
|
||||
|
||||
exec lettuce
|
||||
@@ -1,56 +0,0 @@
|
||||
import psycopg2 as pg
|
||||
from time import sleep
|
||||
|
||||
from lettuce import world, steps
|
||||
|
||||
|
||||
@steps
|
||||
class BasicReplicationSteps(object):
|
||||
|
||||
def __init__(self, environ):
|
||||
self.env = environ
|
||||
|
||||
@staticmethod
|
||||
def start_patroni(step, name):
|
||||
'''I start (\w+)'''
|
||||
return world.pctl.start(name)
|
||||
|
||||
@staticmethod
|
||||
def stop_patroni(step, name):
|
||||
'''I shut down (\w+)'''
|
||||
return world.pctl.stop(name)
|
||||
|
||||
@staticmethod
|
||||
def kill_patroni(step, name):
|
||||
'''I kill (\w+)'''
|
||||
return world.pctl.stop(name, kill=True)
|
||||
|
||||
@staticmethod
|
||||
def add_table(step, table_name, pg_name):
|
||||
'''I add the table (\w+) to (\w+)'''
|
||||
# parse the configuration file and get the port
|
||||
try:
|
||||
world.pctl.query(pg_name, "CREATE TABLE {0}()".format(table_name))
|
||||
except pg.Error as e:
|
||||
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
|
||||
|
||||
@staticmethod
|
||||
def table_is_present_on(step, table_name, pg_name, max_replication_delay):
|
||||
'''Table (\w+) is present on (\w+) after (\d+) seconds'''
|
||||
for _ in range(int(max_replication_delay)):
|
||||
if world.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None:
|
||||
break
|
||||
sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
|
||||
|
||||
@staticmethod
|
||||
def check_role(step, pg_name, pg_role, max_promotion_timeout):
|
||||
'''(\w+) role is the (\w+) after (\d+) seconds'''
|
||||
if not world.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)):
|
||||
assert False,\
|
||||
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
|
||||
|
||||
|
||||
BasicReplicationSteps(world)
|
||||
@@ -1,26 +0,0 @@
|
||||
from lettuce import world, steps
|
||||
|
||||
|
||||
@steps
|
||||
class CascadingReplicationSteps(object):
|
||||
|
||||
def __init__(self, environ):
|
||||
self.env = environ
|
||||
|
||||
@staticmethod
|
||||
def start_patroni_with_a_name_value_tag(step, name, tag_name, tag_value):
|
||||
'''I configure and start (\w+) with a tag (\w+) (\w+)'''
|
||||
return world.pctl.start(name, tags={tag_name: tag_value})
|
||||
|
||||
@staticmethod
|
||||
def check_label(step, content, name):
|
||||
'''There is a label with "(\w+)" in (\w+) data directory'''
|
||||
label = world.pctl.read_label(name)
|
||||
assert label == content, "{0} is not equal to {1}".format(label, content)
|
||||
|
||||
@staticmethod
|
||||
def write_label(step, content, name):
|
||||
'''I create label with "(\w+)" in (\w+) data directory'''
|
||||
world.pctl.write_label(name, content)
|
||||
|
||||
CascadingReplicationSteps(world)
|
||||
@@ -1,4 +1,3 @@
|
||||
from lettuce import world, before, after
|
||||
import os.path
|
||||
import psycopg2
|
||||
import requests
|
||||
@@ -39,13 +38,13 @@ class PatroniController(object):
|
||||
return os.path.join(self.patroni_path, 'data', pg_name)
|
||||
|
||||
def write_label(self, pg_name, content):
|
||||
with open(os.path.join(self.data_dir(pg_name.encode('utf-8')), 'label'), 'w') as f:
|
||||
f.write(content.encode('utf-8'))
|
||||
with open(os.path.join(self.data_dir(pg_name), 'label'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
def read_label(self, pg_name):
|
||||
content = None
|
||||
try:
|
||||
with open(os.path.join(self.data_dir(pg_name.encode('utf-8')), 'label'), 'r') as f:
|
||||
with open(os.path.join(self.data_dir(pg_name), 'label'), 'r') as f:
|
||||
content = f.read()
|
||||
except IOError:
|
||||
return None
|
||||
@@ -120,8 +119,8 @@ class PatroniController(object):
|
||||
self.stop(patroni)
|
||||
|
||||
def create_and_set_output_directory(self, feature_name):
|
||||
feature_dir = os.path.join(pctl.patroni_path, "features", "output",
|
||||
feature_name.encode('utf-8').replace(' ', '_'))
|
||||
feature_dir = os.path.join(self.patroni_path, "features", "output",
|
||||
feature_name.replace(' ', '_'))
|
||||
if os.path.exists(feature_dir):
|
||||
shutil.rmtree(feature_dir)
|
||||
os.makedirs(feature_dir)
|
||||
@@ -137,8 +136,8 @@ class PatroniController(object):
|
||||
with open(patroni_config_name) as f:
|
||||
config = yaml.load(f)
|
||||
postgresql = config['postgresql']
|
||||
postgresql['name'] = pg_name.encode('utf-8')
|
||||
postgresql['data_dir'] = 'data/{0}'.format(pg_name.encode('utf-8'))
|
||||
postgresql['name'] = pg_name
|
||||
postgresql['data_dir'] = 'data/{0}'.format(pg_name)
|
||||
postgresql_params = postgresql['parameters']
|
||||
postgresql_params['logging_collector'] = 'on'
|
||||
postgresql_params['log_destination'] = 'csvlog'
|
||||
@@ -149,9 +148,7 @@ class PatroniController(object):
|
||||
postgresql_params['unix_socket_directories'] = '.'
|
||||
|
||||
if tags:
|
||||
config['tags'] = {}
|
||||
for tag_name in tags:
|
||||
config['tags'][tag_name.encode('utf-8')] = tags[tag_name].encode('utf-8')
|
||||
config['tags'] = tags
|
||||
|
||||
with open(patroni_config_path, 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False)
|
||||
@@ -269,44 +266,35 @@ class EtcdController(object):
|
||||
# if etcd is running, but we didn't start it
|
||||
try:
|
||||
r = requests.get(EtcdController.ETCD_VERSION_URL)
|
||||
running = (r and r.ok and 'etcdserver' in r.content)
|
||||
running = (r and r.ok and b'etcdserver' in r.content)
|
||||
except requests.ConnectionError:
|
||||
running = False
|
||||
return running
|
||||
|
||||
|
||||
pctl = PatroniController()
|
||||
etcd_ctl = EtcdController(pctl.patroni_path)
|
||||
# export pctl to manage patroni from scenario files
|
||||
world.pctl = pctl
|
||||
world.etcd_ctl = etcd_ctl
|
||||
|
||||
|
||||
# actions to execute on start/stop of the tests and before running invidual features
|
||||
@before.all
|
||||
def start_etcd():
|
||||
etcd_ctl.start()
|
||||
def before_all(context):
|
||||
context.pctl = PatroniController()
|
||||
context.etcd_ctl = EtcdController(context.pctl.patroni_path)
|
||||
context.etcd_ctl.start()
|
||||
try:
|
||||
etcd_ctl.cleanup_service_tree()
|
||||
context.etcd_ctl.cleanup_service_tree()
|
||||
except AssertionError: # after.all handlers won't be executed in before.all
|
||||
etcd_ctl.stop_and_remove_work_directory()
|
||||
context.etcd_ctl.stop_and_remove_work_directory()
|
||||
raise
|
||||
|
||||
|
||||
@after.all
|
||||
def stop_etcd(*args, **kwargs):
|
||||
etcd_ctl.stop_and_remove_work_directory()
|
||||
def after_all(context):
|
||||
context.etcd_ctl.stop_and_remove_work_directory()
|
||||
|
||||
|
||||
@before.each_feature
|
||||
def make_test_output_dir(feature):
|
||||
def before_feature(context, feature):
|
||||
""" create per-feature output directory to collect Patroni and PostgreSQL logs """
|
||||
pctl.create_and_set_output_directory(feature.name)
|
||||
context.pctl.create_and_set_output_directory(feature.name)
|
||||
|
||||
|
||||
@after.each_feature
|
||||
def cleanup(*args, **kwargs):
|
||||
def after_feature(context, feature):
|
||||
""" stop all Patronis, remove their data directory and cleanup the keys in etcd """
|
||||
pctl.stop_all()
|
||||
shutil.rmtree(os.path.join(pctl.patroni_path, 'data'))
|
||||
etcd_ctl.cleanup_service_tree()
|
||||
context.pctl.stop_all()
|
||||
shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data'))
|
||||
context.etcd_ctl.cleanup_service_tree()
|
||||
@@ -1,99 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from lettuce import world, steps
|
||||
import time
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
|
||||
@steps
|
||||
class PatroniAPISteps(object):
|
||||
|
||||
def __init__(self, environ):
|
||||
self.env = environ
|
||||
self.response = None
|
||||
self.status_code = None
|
||||
|
||||
# there is no way we can find out if the node has already
|
||||
# started as a leader without checking the DCS. We cannot
|
||||
# just rely on the database availability, since there is
|
||||
# a short gap between the time PostgreSQL becomes available
|
||||
# and Patroni assuming the leader role.
|
||||
@staticmethod
|
||||
def is_a_leader(step, name, time_limit):
|
||||
'''(\w+) is a leader after (\d+) seconds'''
|
||||
max_time = time.time() + int(time_limit)
|
||||
while (world.etcd_ctl.query("leader") != name):
|
||||
time.sleep(1)
|
||||
if time.time() > max_time:
|
||||
assert False, "{0} is not a leader in etcd after {1} seconds".format(name, time_limit)
|
||||
|
||||
@staticmethod
|
||||
def sleep_for_n_seconds(step, value):
|
||||
'''I sleep for (\d+) seconds'''
|
||||
time.sleep(int(value))
|
||||
|
||||
def do_get(self, step, url):
|
||||
'''I issue a GET request to (https?://(?:\w|\.|:|/)+)'''
|
||||
try:
|
||||
r = requests.get(url)
|
||||
except requests.exceptions.RequestException:
|
||||
self.code = None
|
||||
self.response = None
|
||||
else:
|
||||
self.status_code = r.status_code
|
||||
try:
|
||||
self.response = r.json()
|
||||
except ValueError:
|
||||
self.response = r.content
|
||||
|
||||
def do_post_empty(self, step, url):
|
||||
'''I issue an empty POST request to (https?://(?:\w|\.|:|/)+)'''
|
||||
self.do_post(step, url, None)
|
||||
|
||||
def do_post(self, step, url, data):
|
||||
'''I issue a POST request to (https?://(?:\w|\.|:|/)+) with ((?:\w+=(?:\w|\.|:|-|\+|\s)+,?)+)'''
|
||||
post_data = {}
|
||||
if data:
|
||||
post_components = data.split(',')
|
||||
for pc in post_components:
|
||||
if '=' in pc:
|
||||
k, v = pc.split('=', 2)
|
||||
post_data[k.strip()] = v.strip()
|
||||
try:
|
||||
r = requests.post(url, json=post_data)
|
||||
except requests.exceptions.RequestException:
|
||||
self.code = None
|
||||
self.response = None
|
||||
else:
|
||||
self.status_code = r.status_code
|
||||
try:
|
||||
self.response = r.json()
|
||||
except ValueError:
|
||||
self.response = r.content
|
||||
|
||||
def check_response(self, step, component, data):
|
||||
'''I receive a response (\w+) (.*)'''
|
||||
if component == 'code':
|
||||
assert self.status_code == int(data),\
|
||||
"status code {0} != {1}, response: {2}".format(self.status_code, int(data), self.response)
|
||||
elif component == 'text':
|
||||
assert self.response == data.strip('"'), "response {0} does not contain {1}".format(self.response, data)
|
||||
else:
|
||||
assert component in self.response, "{0} is not part of the response".format(component)
|
||||
assert self.response[component] == data, "{0} does not contain {1}".format(component, data)
|
||||
|
||||
def replication_works(self, step, master, replica, time_limit):
|
||||
'''replication works from (\w+) to (\w+) after (\d+) seconds'''
|
||||
step.behave_as("""
|
||||
When I add the table test_{0} to {1}
|
||||
Then table test_{0} is present on {2} after {3} seconds
|
||||
""".format(int(time.time()), master, replica, time_limit))
|
||||
|
||||
def scheduld_failover(self, step, at_url, from_host, to_host, in_seconds):
|
||||
'''I issue a scheduled failover at (https?://(?:\w|\.|:|/)+) from (\w+) to (\w+) in (\d+) seconds'''
|
||||
step.behave_as("""
|
||||
Given I issue a POST request to {0}/failover with leader={1},candidate={2},scheduled_at={3}
|
||||
""".format(at_url, from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds))))
|
||||
|
||||
|
||||
PatroniAPISteps(world)
|
||||
@@ -0,0 +1,55 @@
|
||||
import psycopg2 as pg
|
||||
|
||||
from behave import step, then
|
||||
from time import sleep, time
|
||||
|
||||
|
||||
@step('I start {name}')
|
||||
def start_patroni(context, name):
|
||||
return context.pctl.start(name)
|
||||
|
||||
|
||||
@step('I shut down {name}')
|
||||
def stop_patroni(context, name):
|
||||
return context.pctl.stop(name)
|
||||
|
||||
|
||||
@step('I kill {name}')
|
||||
def kill_patroni(context, name):
|
||||
return context.pctl.stop(name, kill=True)
|
||||
|
||||
|
||||
@step('I add the table {table_name} to {pg_name}')
|
||||
def add_table(context, table_name, pg_name):
|
||||
# parse the configuration file and get the port
|
||||
try:
|
||||
context.pctl.query(pg_name, "CREATE TABLE {0}()".format(table_name))
|
||||
except pg.Error as e:
|
||||
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
|
||||
|
||||
|
||||
@then('Table {table_name} is present on {pg_name} after {max_replication_delay} seconds')
|
||||
def table_is_present_on(context, table_name, pg_name, max_replication_delay):
|
||||
for _ in range(int(max_replication_delay)):
|
||||
if context.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None:
|
||||
break
|
||||
sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
|
||||
|
||||
|
||||
@then('{pg_name} role is the {pg_role} after {max_promotion_timeout} seconds')
|
||||
def check_role(context, pg_name, pg_role, max_promotion_timeout):
|
||||
if not context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)):
|
||||
assert False,\
|
||||
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
|
||||
|
||||
|
||||
@step('replication works from {master} to {replica} after {time_limit} seconds')
|
||||
@then('replication works from {master} to {replica} after {time_limit} seconds')
|
||||
def replication_works(context, master, replica, time_limit):
|
||||
context.execute_steps("""
|
||||
When I add the table test_{0} to {1}
|
||||
Then table test_{0} is present on {2} after {3} seconds
|
||||
""".format(int(time()), master, replica, time_limit))
|
||||
@@ -0,0 +1,17 @@
|
||||
from behave import step, then
|
||||
|
||||
|
||||
@step('I configure and start {name} with a tag {tag_name} {tag_value}')
|
||||
def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value):
|
||||
return context.pctl.start(name, tags={tag_name: tag_value})
|
||||
|
||||
|
||||
@then('There is a label with "{content}" in {name} data directory')
|
||||
def check_label(context, content, name):
|
||||
label = context.pctl.read_label(name)
|
||||
assert label == content, "{0} is not equal to {1}".format(label, content)
|
||||
|
||||
|
||||
@step('I create label with "{content}" in {name} data directory')
|
||||
def write_label(context, content, name):
|
||||
context.pctl.write_label(name, content)
|
||||
@@ -0,0 +1,87 @@
|
||||
import time
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from behave import step, then
|
||||
|
||||
|
||||
# there is no way we can find out if the node has already
|
||||
# started as a leader without checking the DCS. We cannot
|
||||
# just rely on the database availability, since there is
|
||||
# a short gap between the time PostgreSQL becomes available
|
||||
# and Patroni assuming the leader role.
|
||||
@step('{name} is a leader after {time_limit} seconds')
|
||||
@then('{name} is a leader after {time_limit} seconds')
|
||||
def is_a_leader(context, name, time_limit):
|
||||
max_time = time.time() + int(time_limit)
|
||||
while (context.etcd_ctl.query("leader") != name):
|
||||
time.sleep(1)
|
||||
if time.time() > max_time:
|
||||
assert False, "{0} is not a leader in etcd after {1} seconds".format(name, time_limit)
|
||||
|
||||
|
||||
@step('I sleep for {value} seconds')
|
||||
def sleep_for_n_seconds(context, value):
|
||||
time.sleep(int(value))
|
||||
|
||||
|
||||
@step('I issue a GET request to {url}')
|
||||
def do_get(context, url):
|
||||
try:
|
||||
r = requests.get(url)
|
||||
except requests.exceptions.RequestException:
|
||||
context.status_code = None
|
||||
context.response = None
|
||||
else:
|
||||
context.status_code = r.status_code
|
||||
try:
|
||||
context.response = r.json()
|
||||
except ValueError:
|
||||
context.response = r.content.decode('utf-8')
|
||||
|
||||
|
||||
@step('I issue an empty POST request to {url}')
|
||||
def do_post_empty(context, url):
|
||||
do_post(context, url, None)
|
||||
|
||||
|
||||
@step('I issue a POST request to {url} with {data}')
|
||||
def do_post(context, url, data):
|
||||
post_data = {}
|
||||
if data:
|
||||
post_components = data.split(',')
|
||||
for pc in post_components:
|
||||
if '=' in pc:
|
||||
k, v = pc.split('=', 2)
|
||||
post_data[k.strip()] = v.strip()
|
||||
try:
|
||||
r = requests.post(url, json=post_data)
|
||||
except requests.exceptions.RequestException:
|
||||
context.status_code = None
|
||||
context.response = None
|
||||
else:
|
||||
context.status_code = r.status_code
|
||||
try:
|
||||
context.response = r.json()
|
||||
except ValueError:
|
||||
context.response = r.content.decode('utf-8')
|
||||
|
||||
|
||||
@then('I receive a response {component} {data}')
|
||||
def check_response(context, component, data):
|
||||
if component == 'code':
|
||||
assert context.status_code == int(data),\
|
||||
"status code {0} != {1}, response: {2}".format(context.status_code, int(data), context.response)
|
||||
elif component == 'text':
|
||||
assert context.response == data.strip('"'), "response {0} does not contain {1}".format(context.response, data)
|
||||
else:
|
||||
assert component in context.response, "{0} is not part of the response".format(component)
|
||||
assert context.response[component] == data, "{0} does not contain {1}".format(component, data)
|
||||
|
||||
|
||||
@step('I issue a scheduled failover at {at_url} from {from_host} to {to_host} in {in_seconds} seconds')
|
||||
def scheduld_failover(context, at_url, from_host, to_host, in_seconds):
|
||||
context.execute_steps("""
|
||||
Given I issue a POST request to {0}/failover with leader={1},candidate={2},scheduled_at={3}
|
||||
""".format(at_url, from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds))))
|
||||
Reference in New Issue
Block a user