Commit 043ff1e7 authored by Jelte Jansen's avatar Jelte Jansen
Browse files

[1290] address review comments

parent 46adf014
......@@ -123,7 +123,7 @@ class BindCmdInterpreter(Cmd):
'''Parse commands from user and send them to cmdctl. '''
try:
if not self.login_to_cmdctl():
return
return 1
self.cmdloop()
print('\nExit from bindctl')
......
......@@ -112,6 +112,11 @@ Some very general steps are defined in terrain/steps.py.
Initialization code, cleanup code, and helper classes are defined in
terrain/terrain.py.
To find the right steps, case insensitive matching is used. Parameters taken
from the steps are case-sensitive though. So a step defined as
'do foo with value (bar)' will be matched when using
'Do Foo with value xyz', but xyz will be taken as given.
If you need to add steps that are very particular to one test, create a new
file with a name relevant for that test in terrain. We may want to consider
creating a specific subdirectory for these, but at this moment it is unclear
......
......@@ -114,17 +114,27 @@ Feature: showing off BIND 10
So take a look at one of those steps, let's pick the first one.
A step is defined through a python decorator, which in essence is a
regular expression; each captured group will be passed as an argument
to the function we define. For bind10, i defined a configuration file,
a cmdctl port, and a process name. The first two should be
self-evident, and the process name is an optional name we give it,
should we want to address it in the rest of the tests. This is most
useful if we want to start multiple instances. In the next step (the
wait for auth to start), I added a 'of <instance>'. So if we define
the bind10 'as my_bind10', we can specify that one here as 'of my
A step is defined through a python decorator, which in essence is a regular
expression; lettuce searches through all defined steps to find one that
matches. These are 'partial' matches (unless specified otherwise in the
regular expression itself), so if the step is defined with "do foo bar", the
scenario can add words for readability "When I do foo bar".
Each captured group will be passed as an argument to the function we define.
For bind10, i defined a configuration file, a cmdctl port, and a process
name. The first two should be self-evident, and the process name is an
optional name we give it, should we want to address it in the rest of the
tests. This is most useful if we want to start multiple instances. In the
next step (the wait for auth to start), I added a 'of <instance>'. So if we
define the bind10 'as my_bind10', we can specify that one here as 'of my
bind10'.
--
When I start bind10 with configuration second.config
with cmdctl port 12345 as b10_second_instance
--
(line wrapped for readability)
But notice how we needed two steps, which we probably always need (but
not entirely always)? We can also combine steps; for instance:
......
{"version": 2, "Logging": {"loggers": [{"debuglevel": 99, "severity": "DEBUG", "name": "auth"}]}, "Auth": {"database_file": "data/example.org.sqlite3", "listen_on": [{"port": 47806, "address": "127.0.0.1"}]}}
{
"version": 2,
"Logging": {
"loggers": [ {
"debuglevel": 99,
"severity": "DEBUG",
"name": "auth"
} ]
},
"Auth": {
"database_file": "data/example.org.sqlite3",
"listen_on": [ {
"port": 47806,
"address": "127.0.0.1"
} ]
}
}
{"version": 2, "Logging": {"loggers": [{"severity": "DEBUG", "name": "auth", "debuglevel": 99}]}, "Auth": {"database_file": "data/example.org.sqlite3", "listen_on": [{"port": 47807, "address": "127.0.0.1"}]}}
{
"version": 2,
"Logging": {
"loggers": [ {
"severity": "DEBUG",
"name": "auth",
"debuglevel": 99
}
]
},
"Auth": {
"database_file": "data/example.org.sqlite3",
"listen_on": [ {
"port": 47807,
"address": "127.0.0.1"
} ]
}
}
{"version": 2, "Auth": {"database_file": "data/test_nonexistent_db.sqlite3", "listen_on": [{"port": 47806, "address": "127.0.0.1"}]}}
{
"version": 2,
"Auth": {
"database_file": "data/test_nonexistent_db.sqlite3",
"listen_on": [ {
"port": 47806,
"address": "127.0.0.1"
} ]
}
}
......@@ -18,7 +18,7 @@ Feature: Example feature
# that we are sure this file does not exist, see
# features/terrain/terrain.py
# Standard check to test (non-)existance of a file
# Standard check to test (non-)existence of a file
# This file is actually automatically
The file data/test_nonexistent_db.sqlite3 should not exist
......@@ -85,6 +85,8 @@ Feature: Example feature
The last query response should have ancount 0
The last query response should have nscount 1
The last query response should have adcount 0
# When checking flags, we must pass them exactly as they appear in
# the output of dig.
The last query response should have flags qr aa rd
A query for www.example.org type TXT should have rcode NOERROR
......
# Copyright (C) 2011 Internet Systems Consortium.
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
from lettuce import *
import subprocess
import re
def check_lines(output, lines):
for line in lines:
if output.find(line) != -1:
return line
@step('start bind10(?: with configuration (\S+))?' +\
'(?: with cmdctl port (\d+))?(?: as (\S+))?')
def start_bind10(step, config_file, cmdctl_port, process_name):
"""
Start BIND 10 with the given optional config file, cmdctl port, and
store the running process in world with the given process name.
Parameters:
config_file ('with configuration <file>', optional): this configuration
will be used. The path is relative to the base lettuce
directory.
cmdctl_port ('with cmdctl port <portnr>', optional): The port on which
b10-cmdctl listens for bindctl commands. Defaults to 47805.
process_name ('as <name>', optional). This is the name that can be used
in the following steps of the scenario to refer to this
BIND 10 instance. Defaults to 'bind10'.
This call will block until BIND10_STARTUP_COMPLETE or BIND10_STARTUP_ERROR
is logged. In the case of the latter, or if it times out, the step (and
scenario) will fail.
It will also fail if there is a running process with the given process_name
already.
"""
args = [ 'bind10', '-v' ]
if config_file is not None:
args.append('-p')
......@@ -36,6 +64,12 @@ def start_bind10(step, config_file, cmdctl_port, process_name):
@step('wait for bind10 auth (?:of (\w+) )?to start')
def wait_for_auth(step, process_name):
"""Wait for b10-auth to run. This is done by blocking until the message
AUTH_SERVER_STARTED is logged.
Parameters:
process_name ('of <name', optional): The name of the BIND 10 instance
to wait for. Defaults to 'bind10'.
"""
if process_name is None:
process_name = "bind10"
world.processes.wait_for_stderr_str(process_name, ['AUTH_SERVER_STARTED'],
......@@ -43,12 +77,28 @@ def wait_for_auth(step, process_name):
@step('have bind10 running(?: with configuration ([\w.]+))?')
def have_bind10_running(step, config_file):
"""
Compound convenience step for running bind10, which consists of
start_bind10 and wait_for_auth.
Currently only supports the 'with configuration' option.
"""
step.given('start bind10 with configuration ' + config_file)
step.given('wait for bind10 auth to start')
@step('set bind10 configuration (\S+) to (.*)')
def set_config_command(step, name, value):
args = ['bindctl', '-p', '47805']
@step('set bind10 configuration (\S+) to (.*)(?: with cmdctl port (\d+))?')
def set_config_command(step, name, value, cmdctl_port):
"""
Run bindctl, set the given configuration to the given value, and commit it.
Parameters:
name ('configuration <name>'): Identifier of the configuration to set
value ('to <value>'): value to set it to.
cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
the command to. Defaults to 47805.
Fails if cmdctl does not exit with status code 0.
"""
if cmdctl_port is None:
cmdctl_port = '47805'
args = ['bindctl', '-p', cmdctl_port]
bindctl = subprocess.Popen(args, 1, None, subprocess.PIPE,
subprocess.PIPE, None)
bindctl.stdin.write("config set " + name + " " + value + "\n")
......@@ -56,5 +106,3 @@ def set_config_command(step, name, value):
bindctl.stdin.write("quit\n")
result = bindctl.wait()
assert result == 0, "bindctl exit code: " + str(result)
from lettuce import *
import subprocess
import re
# Copyright (C) 2011 Internet Systems Consortium.
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
# This script provides querying functionality
# The most important step is
......@@ -16,6 +27,10 @@ import re
#
# Also see example.feature for some examples
from lettuce import *
import subprocess
import re
#
# define a class to easily access different parts
# We may consider using our full library for this, but for now
......@@ -27,7 +42,8 @@ import re
# The following attributes are 'parsed' from the response, all as strings,
# and end up as direct attributes of the QueryResult object:
# opcode, rcode, id, flags, qdcount, ancount, nscount, adcount
# (flags is one string with all flags)
# (flags is one string with all flags, in the order they appear in the
# response packet.)
#
# this will set 'rcode' as the result code, we 'define' one additional
# rcode, "NO_ANSWER", if the dig process returned an error code itself
......@@ -43,7 +59,19 @@ class QueryResult(object):
"([0-9]+), AUTHORITY: ([0-9]+), ADDITIONAL: ([0-9]+)")
def __init__(self, name, qtype, qclass, address, port):
args = [ 'dig', '+tries=1', '@' + address, '-p', str(port) ]
"""
Constructor. This fires of a query using dig.
Parameters:
name: The domain name to query
qtype: The RR type to query. Defaults to A if it is None.
qclass: The RR class to query. Defaults to IN if it is None.
address: The IP adress to send the query to.
port: The port number to send the query to.
All parameters must be either strings or have the correct string
representation.
Only one query attempt will be made.
"""
args = [ 'dig', '+tries=1', '@' + str(address), '-p', str(port) ]
if qtype is not None:
args.append('-t')
args.append(str(qtype))
......@@ -68,8 +96,9 @@ class QueryResult(object):
self.line_handler(out)
def _check_next_header(self, line):
"""Returns true if we found a next header, and sets the internal
line handler to the appropriate value.
"""
Returns true if we found a next header, and sets the internal
line handler to the appropriate value.
"""
if line == ";; ANSWER SECTION:\n":
self.line_handler = self.parse_answer
......@@ -84,6 +113,11 @@ class QueryResult(object):
return True
def parse_header(self, line):
"""
Parse the header lines of the query response.
Parameters:
line: The current line of the response.
"""
if not self._check_next_header(line):
status_match = self.status_re.search(line)
flags_match = self.flags_re.search(line)
......@@ -98,31 +132,69 @@ class QueryResult(object):
self.adcount = flags_match.group(5)
def parse_question(self, line):
"""
Parse the question section lines of the query response.
Parameters:
line: The current line of the response.
"""
if not self._check_next_header(line):
if line != "\n":
self.question_section.append(line.strip())
def parse_answer(self, line):
"""
Parse the answer section lines of the query response.
Parameters:
line: The current line of the response.
"""
if not self._check_next_header(line):
if line != "\n":
self.answer_section.append(line.strip())
def parse_authority(self, line):
"""
Parse the authority section lines of the query response.
Parameters:
line: The current line of the response.
"""
if not self._check_next_header(line):
if line != "\n":
self.authority_section.append(line.strip())
def parse_authority(self, line):
def parse_additional(self, line):
"""
Parse the additional section lines of the query response.
Parameters:
line: The current line of the response.
"""
if not self._check_next_header(line):
if line != "\n":
self.additional_section.append(line.strip())
def parse_footer(self, line):
"""
Parse the footer lines of the query response.
Parameters:
line: The current line of the response.
"""
pass
@step('A query for ([\w.]+) (?:type ([A-Z]+) )?(?:class ([A-Z]+) )?' +
'(?:to ([^:]+)(?::([0-9]+))? )?should have rcode ([\w.]+)')
def query(step, query_name, qtype, qclass, addr, port, rcode):
"""
Run a query, check the rcode of the response, and store the query
result in world.last_query_result.
Parameters:
query_name ('query for <name>'): The domain name to query.
qtype ('type <type>', optional): The RR type to query. Defaults to A.
qclass ('class <class>', optional): The RR class to query. Defaults to IN.
addr ('to <address>', optional): The IP address of the nameserver to query.
Defaults to 127.0.0.1.
port (':<port>', optional): The port number of the nameserver to query.
Defaults to 47806.
rcode ('should have rcode <rcode>'): The expected rcode of the answer.
"""
if qtype is None:
qtype = "A"
if qclass is None:
......@@ -138,6 +210,15 @@ def query(step, query_name, qtype, qclass, addr, port, rcode):
@step('The SOA serial for ([\w.]+) should be ([0-9]+)')
def query_soa(step, query_name, serial):
"""
Convenience function to check the SOA SERIAL value of the given zone at
the nameserver at the default address (127.0.0.1:47806).
Parameters:
query_name ('for <name>'): The zone to find the SOA record for.
serial ('should be <number>'): The expected value of the SOA SERIAL.
If the rcode is not NOERROR, or the answer section does not contain the
SOA record, this step fails.
"""
query_result = QueryResult(query_name, "SOA", "IN", "127.0.0.1", "47806")
assert "NOERROR" == query_result.rcode,\
"Got " + query_result.rcode + ", expected NOERROR"
......@@ -149,6 +230,16 @@ def query_soa(step, query_name, serial):
@step('last query response should have (\S+) (.+)')
def check_last_query(step, item, value):
"""
Check a specific value in the reponse from the last successful query sent.
Parameters:
item: The item to check the value of
value: The expected value.
This performs a very simple direct string comparison of the QueryResult
member with the given item name and the given value.
Fails if the item is unknown, or if its value does not match the expected
value.
"""
assert world.last_query_result is not None
assert item in world.last_query_result.__dict__
lq_val = world.last_query_result.__dict__[item]
......@@ -157,6 +248,17 @@ def check_last_query(step, item, value):
@step('([a-zA-Z]+) section of the last query response should be')
def check_last_query_section(step, section):
"""
Check the entire contents of the given section of the response of the last
query.
Parameters:
section ('<section> section'): The name of the section (QUESTION, ANSWER,
AUTHORITY or ADDITIONAL).
The expected response is taken from the multiline part of the step in the
scenario. Differing whitespace is ignored, but currently the order is
significant.
Fails if they do not match.
"""
response_string = None
if section.lower() == 'question':
response_string = "\n".join(world.last_query_result.question_section)
......
# Copyright (C) 2011 Internet Systems Consortium.
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
# This file contains a number of common steps that are general and may be used
# By a lot of feature files.
......@@ -8,18 +23,50 @@ import os
@step('stop process (\w+)')
def stop_a_named_process(step, process_name):
"""
Stop the process with the given name.
Parameters:
process_name ('process <name>'): Name of the process to stop.
"""
world.processes.stop_process(process_name)
@step('wait for (new )?(\w+) stderr message (\w+)')
def wait_for_message(step, new, process_name, message):
"""
Block until the given message is printed to the given process's stderr
output.
Parameter:
new: (' new', optional): Only check the output printed since last time
this step was used for this process.
process_name ('<name> stderr'): Name of the process to check the output of.
message ('message <message>'): Output (part) to wait for.
Fails if the message is not found after 10 seconds.
"""
world.processes.wait_for_stderr_str(process_name, [message], new)
@step('wait for (new )?(\w+) stdout message (\w+)')
def wait_for_message(step, process_name, message):
"""
Block until the given message is printed to the given process's stdout
output.
Parameter:
new: (' new', optional): Only check the output printed since last time
this step was used for this process.
process_name ('<name> stderr'): Name of the process to check the output of.
message ('message <message>'): Output (part) to wait for.
Fails if the message is not found after 10 seconds.
"""
world.processes.wait_for_stdout_str(process_name, [message], new)
@step('the file (\S+) should (not )?exist')
def check_existence(step, file_name, should_not_exist):
"""
Check the existence of the given file.
Parameters:
file_name ('file <name>'): File to check existence of.
should_not_exist ('not', optional): Whether it should or should not exist.
Fails if the file should exist and does not, or vice versa.
"""
if should_not_exist is None:
assert os.path.exists(file_name), file_name + " does not exist"
else:
......
# Copyright (C) 2011 Internet Systems Consortium.
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
# This is the 'terrain' in which the lettuce lives. By convention, this is
# where global setup and teardown is defined.
......@@ -7,6 +22,7 @@
#
# We also use it to provide scenario invariants, such as resetting data.
#
from lettuce import *
import subprocess
import os.path
......@@ -46,6 +62,15 @@ OUTPUT_WAIT_MAX_INTERVALS = 20
class RunningProcess:
def __init__(self, step, process_name, args):
# set it to none first so destructor won't error if initializer did
"""
Initialize the long-running process structure, and start the process.
Parameters:
step: The scenario step it was called from. This is used for
determining the output files for redirection of stdout
and stderr.
process_name: The name to refer to this running process later.
args: Array of arguments to pass to Popen().
"""
self.process = None
self.step = step
self.process_name = process_name
......@@ -55,6 +80,12 @@ class RunningProcess:
self._start_process(args)
def _start_process(self, args):
"""
Start the process.
Parameters:
args:
Array of arguments to pass to Popen().
"""
stderr_write = open(self.stderr_filename, "w")
stdout_write = open(self.stdout_filename, "w")
self.process = subprocess.Popen(args, 1, None, subprocess.PIPE,
......@@ -64,6 +95,16 @@ class RunningProcess:
self.stdout = open(self.stdout_filename, "r")
def mangle_filename(self, filebase, extension):
"""
Remove whitespace and non-default characters from a base string,
and return the substituted value. Whitespace is replaced by an
underscore. Any other character that is not an ASCII letter, a
number, a dot, or a hyphen or underscore is removed.
Parameter:
filebase: The string to perform the substitution and removal on
extension: An extension to append to the result value
Returns the modified filebase with the given extension
"""
filebase = re.sub("\s+", "_", filebase)
filebase = re.sub("[^a-zA-Z0-9.\-_]", "", filebase)
return filebase + "." + extension
......@@ -73,6 +114,12 @@ class RunningProcess:
# through an environment variable. Since we currently expect
# lettuce to be run from our lettuce dir, we shall just use
# the relative path 'output/'
"""
Make sure the output directory for stdout/stderr redirection
exists.
Fails if it exists but is not a directory, or if it does not
and we are unable to create it.
"""
self._output_dir = os.getcwd() + os.sep + "output"
if not os.path.exists(self._output_dir):
os.mkdir(self._output_dir)
......@@ -80,6 +127,11 @@ class RunningProcess:
self._output_dir + " is not a directory."
def _create_filenames(self):
"""
Derive the filenames for stdout/stderr redirection from the
feature, scenario, and process name. The base will be
"<Feature>-<Scenario>-<process name>.[stdout|stderr]"
"""
filebase = self.step.scenario.feature.name + "-" +\
self.step.scenario.name + "-" + self.process_name
self.stderr_filename = self._output_dir + os.sep +\
......@@ -88,6 +140,11 @@ class RunningProcess:
self.mangle_filename(filebase, "stdout")
def stop_process(self):
"""
Stop this process by calling terminate(). Blocks until process has
exited. If remove_files_on_exit is True, redirected output files
are removed.
"""
if self.process is not None:
self.process.terminate()
self.process.wait()
......@@ -96,10 +153,30 @@ class RunningProcess:
self._remove_files()
def _remove_files(self):
"""
Remove the files created for redirection of stdout/stderr output.
"""
os.remove(self.stderr_filename)
os.remove(self.stdout_filename)