Skip to content

Commit

Permalink
test: add free port auto resolve mechanism
Browse files Browse the repository at this point in the history
Added free port auto resolve mechanism for
integration tests, which require the running
of a tarantool instance.

Closes tarantool#71
  • Loading branch information
GRISHNOV authored and LeonidVas committed Oct 18, 2022
1 parent e6e467b commit c314fd6
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 17 deletions.
6 changes: 5 additions & 1 deletion test/integration/play/test_file/remote_instance_cfg.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
-- will be transferred for testing tt play command.
-- A test space 'tester' is being created, the data about which
-- will be present in the transmitted .xlog file during testing tt play.
-- Call require('utils').bind_free_port(arg[0]) is required for using
-- TarantoolTestInstance class of test/utils.py.

local box = require('box')
-- The module below should be in a pytest temporary directory.
local testutils = require('utils')

local function configure_instance()
box.cfg{listen = 3301}
testutils.bind_free_port(arg[0]) -- arg[0] is 'remote_instance_cfg.lua'
local tester = box.schema.space.create('tester', {id = 999})
tester:format(
{
Expand Down
41 changes: 25 additions & 16 deletions test/integration/play/test_play.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
import re
import shutil
import subprocess
from time import sleep

from utils import run_command_and_get_output
from utils import TarantoolTestInstance, run_command_and_get_output

# The name of instance config file within this integration tests.
# This file should be in /test/integration/play/test_file/.
INSTANCE_NAME = "remote_instance_cfg.lua"


def test_play_unset_arg(tt_cmd, tmpdir):
Expand All @@ -17,37 +19,44 @@ def test_play_unset_arg(tt_cmd, tmpdir):

def test_play_non_existent_uri(tt_cmd, tmpdir):
# Testing with non-existent uri.
cmd = [tt_cmd, "play", "localhost:0", "_"]
cmd = [tt_cmd, "play", "127.0.0.1:0", "_"]
rc, output = run_command_and_get_output(cmd, cwd=tmpdir)
assert rc == 1
assert re.search(r"no connection to the host", output)


def test_play_non_existent_file(tt_cmd, tmpdir):
# Testing with non-existent .xlog or .snap file.
cmd = [tt_cmd, "play", "localhost:49001", "path-to-non-existent-file"]
instance = subprocess.Popen(["tarantool", "-e", "box.cfg{listen = 'localhost:49001';}"])
# The delay is needed so that the instance has time to start and configure itself
sleep(1)
test_app_path = os.path.join(os.path.dirname(__file__), "test_file")

# Create tarantool instance for testing and start it.
path_to_lua_utils = os.path.join(os.path.dirname(__file__), "test_file/../../../")
test_instance = TarantoolTestInstance(INSTANCE_NAME, test_app_path, path_to_lua_utils, tmpdir)
test_instance.start()

# Run play with non-existent file.
cmd = [tt_cmd, "play", "127.0.0.1:" + test_instance.port, "path-to-non-existent-file"]
rc, output = run_command_and_get_output(cmd, cwd=tmpdir)
instance.kill()
test_instance.stop()
assert rc == 1
assert re.search(r"No such file or directory", output)


def test_play_test_remote_instance(tt_cmd, tmpdir):
# Copy the .xlog and instance config files to the "run" directory.
# Testing play using remote instance.
test_app_path = os.path.join(os.path.dirname(__file__), "test_file")
# Copy the .xlog file to the "run" directory.
shutil.copy(test_app_path + "/test.xlog", tmpdir)
shutil.copy(test_app_path + "/remote_instance_cfg.lua", tmpdir)

# Create tarantool instance for testing and start it.
path_to_lua_utils = os.path.join(os.path.dirname(__file__), "test_file/../../../")
test_instance = TarantoolTestInstance(INSTANCE_NAME, test_app_path, path_to_lua_utils, tmpdir)
test_instance.start()

# Play .xlog file to the remote instance.
cmd = [tt_cmd, "play", "localhost:3301", "test.xlog", "--space=999"]
instance = subprocess.Popen(["tarantool", "remote_instance_cfg.lua"], cwd=tmpdir)
# The delay is needed so that the instance has time to start and configure itself
sleep(1)
cmd = [tt_cmd, "play", "127.0.0.1:" + test_instance.port, "test.xlog", "--space=999"]
rc, output = run_command_and_get_output(cmd, cwd=tmpdir)
instance.kill()
test_instance.stop()
assert rc == 0
assert re.search(r"Play result: completed successfully", output)

Expand Down
101 changes: 101 additions & 0 deletions test/utils.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
-- This module contains auxiliary functions for integration tests that use lua code.
-- To use this module, you need to copy this file to the temp pytest directory for the duration
-- of the integration tests, then use the lua require function in your instance cfg file.
-- The copying of this file to pytest temdir is already implemented in class
-- TarantoolTestInstance of test/utils.py module.
-- Just use require('utils').bind_free_port(arg[0]) inside your cfg instance file.
-- If you need to get the port value in the lua test instance cfg file,
-- use require('utils').get_bound_port() after require('utils').bind_free_port(arg[0]).

local box = require('box')
local ffi = require('ffi')
local socket = require('socket')

local testutils = {}

function testutils.get_bound_address_old_tarantool()
-- Get bound via box.cfg({listen = '127.0.0.1:0'}) socket addr for tarantool major version 1.
-- For tarantool major version higher then 1, this function can be replaced by box.info.listen.
-- Table res contains listening and client sockets.
local res = {}
for fd = 0, 65535 do
local addrinfo = socket.internal.name(fd)
-- Assume that the socket listens on 127.0.0.1.
local is_matched = addrinfo ~= nil and addrinfo.host == '127.0.0.1' and
addrinfo.family == 'AF_INET' and addrinfo.type == 'SOCK_STREAM' and
addrinfo.protocol == 'tcp' and type(addrinfo.port) == 'number'
if is_matched then
addrinfo.fd = fd
table.insert(res, addrinfo)
end
end

-- Table l_res contains listening sockets.
local l_res = {}
-- We need only listening, not client sockets.
-- Filters sockets with SO_REUSEADDR.
for _, sock in pairs(res) do
local value = ffi.new('int[1]')
local len = ffi.new('size_t[1]', ffi.sizeof('int'))
local level = socket.internal.SOL_SOCKET
local status = ffi.C.getsockopt(
sock.fd,
level,
socket.internal.SO_OPT[level].SO_REUSEADDR.iname,
value, len
)
if status ~= 0 then
error('problem with calling getsockopt() function')
end
if value[0] > 0 then
table.insert(l_res, sock)
end
end

-- If there are several listening sockets, we don't know which one is iproto's one.
if #l_res ~= 1 then
error(('zero or more than one listening TCP sockets: %d'):format(#l_res))
end

return ('%s:%s'):format(l_res[1].host, l_res[1].port)
end

function testutils.get_bound_port()
-- Returns bound port.
-- Can be used after call testutils.bind_free_port() if you need to get the port value
-- in the lua test instance cfg file.
local address = box.info.listen
if address == nil then
-- In case of tarantool major version 1.
address = testutils.get_bound_address_old_tarantool()
end
-- Get port from address string '127.0.0.1:*'.
local port = address:match(':(.*)')

if port == nil then
error('unable to get bound port, perhaps forgot to call testutils.bind_free_port()')
end

return port
end

function testutils.dump_bound_port(instance_file_name)
-- Dump bound port to file for pytest.
-- It will be regular text file which name is 'instance_file_name.port'.
local file, err = io.open(instance_file_name .. '.port', 'w')
if file == nil then
error(err)
end

local port = testutils.get_bound_port()
file:write(port)
file:close()
end

function testutils.bind_free_port(instance_file_name)
-- Bind free port and save it to file for pytest.
box.cfg({listen = '127.0.0.1:0'})
testutils.dump_bound_port(instance_file_name)
end

return testutils
122 changes: 122 additions & 0 deletions test/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os
import re
import shutil
import subprocess
import time

import psutil
import tarantool
import yaml


Expand Down Expand Up @@ -137,3 +139,123 @@ def wait_instance_stop(pid_path, timeout_sec=5):
iter_count = iter_count + 1

return stopped


class TarantoolTestInstance:
"""Create test tarantool instance via subprocess.Popen with given cfg file.
Performs this steps for it:
1) Copy the instance config files to the run pytest tmp directory.
This cfg file should be in /test/integration/foo-module/test_file/instance_cfg_file_name.
But you can specify different path by instance_cfg_file_dir arg.
2) Also copy to pytest tmpdir the lua module utils.lua with the auxiliary functions.
This functions is required for using with your instance.
As a result, you can use require('utils') inside your instance config file.
Arg path_to_lua_utils should specify dir with utils.lua file.
3) Run tarantool via subprocess.Popen with given cfg file.
Gets bound port from tmpdir.
Init subprocess object and instance's port as attributes.
NOTE: Demand require('utils').bind_free_port(arg[0]) inside your instance cfg file.
Args:
instance_cfg_file_name (str): file name of your test instance cfg.
instance_cfg_file_dir (str): path to dir that contains instance_cfg_file_name.
path_to_lua_utils (str): path to dir that contains utils.lua file.
tmpdir (str): expected result of fixture get_tmpdir from conftest.
Attributes:
popen_obj (Popen[bytes]): subprocess.Popen object with tarantool test instance.
port (str): port of tarantool test instance.
Methods:
start():
Starts tarantool test instance and init self.port attribute.
stop():
Stops tarantool test instance by SIGKILL signal.
"""

def __init__(self, instance_cfg_file_name, instance_cfg_file_dir, path_to_lua_utils, tmpdir):
# Copy the instance config files to the run pytest tmpdir directory.
shutil.copy(instance_cfg_file_dir + "/" + instance_cfg_file_name, tmpdir)
# Copy the lua module with the auxiliary functions required by the instance config file.
shutil.copy(path_to_lua_utils + "/" + "utils.lua", tmpdir)

self._tmpdir = tmpdir
self._instance_cfg_file_name = instance_cfg_file_name

def start(self, connection_test=True,
connection_test_user='guest',
connection_test_password=None):
"""Starts tarantool test instance and init self.port attribute.
Args:
connection_test (bool): if this flag is set, then after bound the port, an attempt will be
made to connect to the test instance within a three second deadline. (default is True)
connection_test_user (str): username for the connection attempt. (default is 'guest')
connection_test_password (str): password for the connection attempt. (default is None)
Raises:
RuntimeError:
If could not find a file with an instance bound port during 3 seconds deadline.
You may have forgotten to use require('utils').bind_free_port(arg[0])
inside your cfg instance file.
Also, this exception will occur if it is impossible to connect to a started
instance within three seconds deadline after port bound (an attempt to connect is
made if there is an option connection_test=True that is present by default).
"""
popen_obj = subprocess.Popen(["tarantool", self._instance_cfg_file_name], cwd=self._tmpdir)
file_with_port_path = str(self._tmpdir) + '/' + self._instance_cfg_file_name + '.port'

# Waiting 3 seconds for instance configure itself and dump bound port to temp file.
deadline = time.time() + 3
while time.time() < deadline:
if os.path.exists(file_with_port_path) and os.path.getsize(file_with_port_path) > 0:
break
time.sleep(0.1)
else:
raise RuntimeError('Could not find a file with an instance bound port or empty file')

# Read bound port of test instance from file in temp pytest directory.
with open(file_with_port_path) as file_with_port:
instance_port = file_with_port.read()

# Tries connect to the started instance during 3 seconds deadline with bound port.
if connection_test:
deadline = time.time() + 3
while time.time() < deadline:
try:
conn = tarantool.connect("localhost", int(instance_port),
user=connection_test_user,
password=connection_test_password)
conn.close()
break
except tarantool.NetworkError:
time.sleep(0.1)
else:
raise RuntimeError('Could not connect to the started instance with bound port')

self.popen_obj = popen_obj
self.port = instance_port

def stop(self):
"""Stops tarantool test instance by SIGKILL signal.
Raises:
RuntimeError:
If could not stop instance after receiving SIGKILL during 3 seconds deadline.
"""
self.popen_obj.kill()
instance = psutil.Process(self.popen_obj.pid)
# Waiting for the completion of the process with 3 second timeout.
deadline = time.time() + 3
while time.time() < deadline:
if not psutil.pid_exists(instance.pid) or instance.status() == 'zombie':
# There is no more instance process or it is zombie.
break
else:
time.sleep(0.1)
else:
raise RuntimeError("PID {} couldn't stop after receiving SIGKILL".format(instance.pid))

0 comments on commit c314fd6

Please sign in to comment.