You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by ni...@apache.org on 2020/05/25 08:08:16 UTC

[ignite] 01/24: ignite-ducktape: Initial commit.

This is an automated email from the ASF dual-hosted git repository.

nizhikov pushed a commit to branch ignite-ducktape
in repository https://gitbox.apache.org/repos/asf/ignite.git

commit d970146620834490f735fc4a1eee498e4b64f68d
Author: Nikolay Izhikov <ni...@apache.org>
AuthorDate: Tue Apr 7 18:01:34 2020 +0300

    ignite-ducktape: Initial commit.
---
 tests/MANIFEST.in                        |  16 +
 tests/docker/Dockerfile                  |  63 ++++
 tests/docker/build/cluster.json          | 163 +++++++++
 tests/docker/build/node_hosts            |  14 +
 tests/docker/ducker-ignite               | 580 +++++++++++++++++++++++++++++++
 tests/docker/run_tests.sh                |  30 ++
 tests/docker/ssh-config                  |  21 ++
 tests/docker/ssh/authorized_keys         |  15 +
 tests/docker/ssh/config                  |  21 ++
 tests/docker/ssh/id_rsa                  |  27 ++
 tests/docker/ssh/id_rsa.pub              |   1 +
 tests/ignitetest/__init__.py             |  25 ++
 tests/ignitetest/utils/__init__.py       |  16 +
 tests/ignitetest/utils/remote_account.py |  41 +++
 tests/ignitetest/utils/util.py           | 138 ++++++++
 tests/ignitetest/version.py              | 140 ++++++++
 tests/setup.cfg                          |  30 ++
 tests/setup.py                           |  57 +++
 18 files changed, 1398 insertions(+)

diff --git a/tests/MANIFEST.in b/tests/MANIFEST.in
new file mode 100644
index 0000000..edef4ab
--- /dev/null
+++ b/tests/MANIFEST.in
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+recursive-include ignitetest */templates/*
diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile
new file mode 100644
index 0000000..0ee09b4
--- /dev/null
+++ b/tests/docker/Dockerfile
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+ARG jdk_version=openjdk:8
+FROM $jdk_version
+
+MAINTAINER Apache Ignite dev@ignite.apache.org
+VOLUME ["/opt/ignite-dev"]
+
+# Set the timezone.
+ENV TZ="/usr/share/zoneinfo/America/Los_Angeles"
+
+# Do not ask for confirmations when running apt-get, etc.
+ENV DEBIAN_FRONTEND noninteractive
+
+# Set the ducker.creator label so that we know that this is a ducker image.  This will make it
+# visible to 'ducker purge'.  The ducker.creator label also lets us know what UNIX user built this
+# image.
+ARG ducker_creator=default
+LABEL ducker.creator=$ducker_creator
+
+# Update Linux and install necessary utilities.
+RUN apt update && apt install -y sudo netcat iptables rsync unzip wget curl jq coreutils openssh-server net-tools vim python-pip python-dev libffi-dev libssl-dev cmake pkg-config libfuse-dev iperf traceroute && apt-get -y clean
+RUN python -m pip install -U pip==9.0.3;
+RUN pip install --upgrade cffi virtualenv pyasn1 boto3 pycrypto pywinrm ipaddress enum34 && pip install --upgrade ducktape==0.7.6
+
+# Set up ssh
+COPY ./ssh-config /root/.ssh/config
+# NOTE: The paramiko library supports the PEM-format private key, but does not support the RFC4716 format.
+RUN ssh-keygen -m PEM -q -t rsa -N '' -f /root/.ssh/id_rsa && cp -f /root/.ssh/id_rsa.pub /root/.ssh/authorized_keys
+RUN echo 'PermitUserEnvironment yes' >> /etc/ssh/sshd_config
+
+# Install binary test dependencies.
+# we use the same versions as in vagrant/base.sh
+ARG IGNITE_MIRROR="https://apache-mirror.rbc.ru/pub/apache/"
+ARG ARCHIVE_NAME="apache-ignite-2.8.0-bin.zip"
+RUN mkdir -p "/opt/ignite-2.8.0" && chmod a+rw /opt/ignite-2.8.0 && cd /opt/ignite-2.8.0 && curl -s "$IGNITE_MIRROR/ignite/2.8.0/$ARCHIVE_NAME" > /opt/ignite-2.8.0/$ARCHIVE_NAME && unzip /opt/ignite-2.8.0/$ARCHIVE_NAME -d /opt/ignite-2.8.0/
+
+# The version of Kibosh to use for testing.
+# If you update this, also update vagrant/base.sh
+ARG KIBOSH_VERSION="8841dd392e6fbf02986e2fb1f1ebf04df344b65a"
+
+# Install Kibosh
+RUN apt-get install fuse
+RUN cd /opt && git clone -q  https://github.com/confluentinc/kibosh.git && cd "/opt/kibosh" && git reset --hard $KIBOSH_VERSION && mkdir "/opt/kibosh/build" && cd "/opt/kibosh/build" && ../configure && make -j 2
+
+# Set up the ducker user.
+RUN useradd -ms /bin/bash ducker && mkdir -p /home/ducker/ && rsync -aiq /root/.ssh/ /home/ducker/.ssh && chown -R ducker /home/ducker/ /mnt/ /var/log/ && echo "PATH=$(runuser -l ducker -c 'echo $PATH'):$JAVA_HOME/bin" >> /home/ducker/.ssh/environment && echo 'PATH=$PATH:'"$JAVA_HOME/bin" >> /home/ducker/.profile && echo 'ducker ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
+USER ducker
+
+CMD sudo service ssh start && tail -f /dev/null
diff --git a/tests/docker/build/cluster.json b/tests/docker/build/cluster.json
new file mode 100644
index 0000000..3597cac
--- /dev/null
+++ b/tests/docker/build/cluster.json
@@ -0,0 +1,163 @@
+{
+  "_comment": [
+    "Licensed to the Apache Software Foundation (ASF) under one or more",
+    "contributor license agreements.  See the NOTICE file distributed with",
+    "this work for additional information regarding copyright ownership.",
+    "The ASF licenses this file to You 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."
+  ],
+  "nodes": [
+    {
+      "externally_routable_ip": "ducker02",
+      "ssh_config": {
+        "host": "ducker02",
+        "hostname": "ducker02",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker03",
+      "ssh_config": {
+        "host": "ducker03",
+        "hostname": "ducker03",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker04",
+      "ssh_config": {
+        "host": "ducker04",
+        "hostname": "ducker04",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker05",
+      "ssh_config": {
+        "host": "ducker05",
+        "hostname": "ducker05",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker06",
+      "ssh_config": {
+        "host": "ducker06",
+        "hostname": "ducker06",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker07",
+      "ssh_config": {
+        "host": "ducker07",
+        "hostname": "ducker07",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker08",
+      "ssh_config": {
+        "host": "ducker08",
+        "hostname": "ducker08",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker09",
+      "ssh_config": {
+        "host": "ducker09",
+        "hostname": "ducker09",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker10",
+      "ssh_config": {
+        "host": "ducker10",
+        "hostname": "ducker10",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker11",
+      "ssh_config": {
+        "host": "ducker11",
+        "hostname": "ducker11",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker12",
+      "ssh_config": {
+        "host": "ducker12",
+        "hostname": "ducker12",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker13",
+      "ssh_config": {
+        "host": "ducker13",
+        "hostname": "ducker13",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    },
+    {
+      "externally_routable_ip": "ducker14",
+      "ssh_config": {
+        "host": "ducker14",
+        "hostname": "ducker14",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    }
+  ]
+}
diff --git a/tests/docker/build/node_hosts b/tests/docker/build/node_hosts
new file mode 100644
index 0000000..e0aba24
--- /dev/null
+++ b/tests/docker/build/node_hosts
@@ -0,0 +1,14 @@
+172.19.0.2	ducker01
+172.19.0.3	ducker02
+172.19.0.4	ducker03
+172.19.0.5	ducker04
+172.19.0.6	ducker05
+172.19.0.7	ducker06
+172.19.0.8	ducker07
+172.19.0.9	ducker08
+172.19.0.10	ducker09
+172.19.0.11	ducker10
+172.19.0.12	ducker11
+172.19.0.13	ducker12
+172.19.0.14	ducker13
+172.19.0.15	ducker14
diff --git a/tests/docker/ducker-ignite b/tests/docker/ducker-ignite
new file mode 100755
index 0000000..6ea13a0
--- /dev/null
+++ b/tests/docker/ducker-ignite
@@ -0,0 +1,580 @@
+#!/usr/bin/env bash
+
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+#
+# Ducker-Ignite: a tool for running Apache Ignite system tests inside Docker images.
+#
+# Note: this should be compatible with the version of bash that ships on most
+# Macs, bash 3.2.57.
+#
+
+script_path="${0}"
+
+# The absolute path to the directory which this script is in.  This will also be the directory
+# which we run docker build from.
+ducker_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+# The absolute path to the root Ignite directory
+ignite_dir="$( cd "${ducker_dir}/../.." && pwd )"
+
+# The memory consumption to allow during the docker build.
+# This does not include swap.
+docker_build_memory_limit="3200m"
+
+# The maximum mmemory consumption to allow in containers.
+docker_run_memory_limit="2000m"
+
+# The default number of cluster nodes to bring up if a number is not specified.
+default_num_nodes=14
+
+# The default OpenJDK base image.
+default_jdk="openjdk:8"
+
+# The default ducker-ignite image name.
+default_image_name="ducker-ak"
+
+# Display a usage message on the terminal and exit.
+#
+# $1: The exit status to use
+usage() {
+    local exit_status="${1}"
+    cat <<EOF
+ducker-ak: a tool for running Apache Ignite tests inside Docker images.
+
+Usage: ${script_path} [command] [options]
+
+help|-h|--help
+    Display this help message
+
+up [-n|--num-nodes NUM_NODES] [-f|--force] [docker-image]
+        [-C|--custom-ducktape DIR] [-e|--expose-ports ports]
+    Bring up a cluster with the specified amount of nodes (defaults to ${default_num_nodes}).
+    The docker image name defaults to ${default_image_name}.  If --force is specified, we will
+    attempt to bring up an image even some parameters are not valid.
+
+    If --custom-ducktape is specified, we will install the provided custom
+    ducktape source code directory before bringing up the nodes.  The provided
+    directory should be the ducktape git repo, not the ducktape installed module directory.
+
+    if --expose-ports is specified then we will expose those ports to random ephemeral ports
+    on the host. The argument can be a single port (like 5005), a port range like (5005-5009)
+    or a combination of port/port-range separated by comma (like 2181,9092 or 2181,5005-5008).
+    By default no port is exposed. See README.md for more detail on this option.
+
+test [test-name(s)]
+    Run a test or set of tests inside the currently active Ducker nodes.
+    For example, to run the system test produce_bench_test, you would run:
+        ./tests/docker/ducker-ak test ./tests/ignitetest/test/core/rebalance_test.py
+
+ssh [node-name|user-name@node-name] [command]
+    Log in to a running ducker container.  If node-name is not given, it prints
+    the names of all running nodes.  If node-name is 'all', we will run the
+    command on every node.  If user-name is given, we will try to log in as
+    that user.  Otherwise, we will log in as the 'ducker' user.  If a command
+    is specified, we will run that command.  Otherwise, we will provide a login
+    shell.
+
+down [-q|--quiet] [-f|--force]
+    Tear down all the currently active ducker-ak nodes.  If --quiet is specified,
+    only error messages are printed. If --force or -f is specified, "docker rm -f"
+    will be used to remove the nodes, which kills currently running ducker-ak test.
+
+purge [--f|--force]
+    Purge Docker images created by ducker-ak.  This will free disk space.
+    If --force is set, we run 'docker rmi -f'.
+EOF
+    exit "${exit_status}"
+}
+
+# Exit with an error message.
+die() {
+    echo $@
+    exit 1
+}
+
+# Check for the presence of certain commands.
+#
+# $@: The commands to check for.  This function will die if any of these commands are not found by
+#       the 'which' command.
+require_commands() {
+    local cmds="${@}"
+    for cmd in ${cmds}; do
+        which -- "${cmd}" &> /dev/null || die "You must install ${cmd} to run this script."
+    done
+}
+
+# Set a global variable to a value.
+#
+# $1: The variable name to set.  This function will die if the variable already has a value.  The
+#     variable will be made readonly to prevent any future modifications.
+# $2: The value to set the variable to.  This function will die if the value is empty or starts
+#     with a dash.
+# $3: A human-readable description of the variable.
+set_once() {
+    local key="${1}"
+    local value="${2}"
+    local what="${3}"
+    [[ -n "${!key}" ]] && die "Error: more than one value specified for ${what}."
+    verify_command_line_argument "${value}" "${what}"
+    # It would be better to use declare -g, but older bash versions don't support it.
+    export ${key}="${value}"
+}
+
+# Verify that a command-line argument is present and does not start with a slash.
+#
+# $1: The command-line argument to verify.
+# $2: A human-readable description of the variable.
+verify_command_line_argument() {
+    local value="${1}"
+    local what="${2}"
+    [[ -n "${value}" ]] || die "Error: no value specified for ${what}"
+    [[ ${value} == -* ]] && die "Error: invalid value ${value} specified for ${what}"
+}
+
+# Echo a message if a flag is set.
+#
+# $1: If this is 1, the message will be echoed.
+# $@: The message
+maybe_echo() {
+    local verbose="${1}"
+    shift
+    [[ "${verbose}" -eq 1 ]] && echo "${@}"
+}
+
+# Counts the number of elements passed to this subroutine.
+count() {
+    echo $#
+}
+
+# Push a new directory on to the bash directory stack, or exit with a failure message.
+#
+# $1: The directory push on to the directory stack.
+must_pushd() {
+    local target_dir="${1}"
+    pushd -- "${target_dir}" &> /dev/null || die "failed to change directory to ${target_dir}"
+}
+
+# Pop a directory from the bash directory stack, or exit with a failure message.
+must_popd() {
+    popd &> /dev/null || die "failed to popd"
+}
+
+# Run a command and die if it fails.
+#
+# Optional flags:
+# -v: print the command before running it.
+# -o: display the command output.
+# $@: The command to run.
+must_do() {
+    local verbose=0
+    local output="/dev/null"
+    while true; do
+        case ${1} in
+            -v) verbose=1; shift;;
+            -o) output="/dev/stdout"; shift;;
+            *) break;;
+        esac
+    done
+    local cmd="${@}"
+    [[ "${verbose}" -eq 1 ]] && echo "${cmd}"
+    ${cmd} >${output} || die "${1} failed"
+}
+
+# Ask the user a yes/no question.
+#
+# $1: The prompt to use
+# $_return: 0 if the user answered no; 1 if the user anМинус - создаст дополнительную нагрузку на core team.swered yes.
+ask_yes_no() {
+    local prompt="${1}"
+    while true; do
+        read -r -p "${prompt} " response
+        case "${response}" in
+            [yY]|[yY][eE][sS]) _return=1; return;;
+            [nN]|[nN][oO]) _return=0; return;;
+            *);;
+        esac
+        echo "Please respond 'yes' or 'no'."
+        echo
+    done
+}
+
+# Build a docker image.
+#
+# $1: The name of the image to build.
+ducker_build() {
+    local image_name="${1}"
+
+    # Use SECONDS, a builtin bash variable that gets incremented each second, to measure the docker
+    # build duration.
+    SECONDS=0
+
+    must_pushd "${ducker_dir}"
+    # Tip: if you are scratching your head for some dependency problems that are referring to an old code version
+    # (for example java.lang.NoClassDefFoundError), add --no-cache flag to the build shall give you a clean start.
+    must_do -v -o docker build --memory="${docker_build_memory_limit}" \
+        --build-arg "ducker_creator=${user_name}" --build-arg "jdk_version=${jdk_version}" -t "${image_name}" \
+        -f "${ducker_dir}/Dockerfile" ${docker_args} -- .
+    docker_status=$?
+    must_popd
+    duration="${SECONDS}"
+    if [[ ${docker_status} -ne 0 ]]; then
+        die "** ERROR: Failed to build ${what} image after $((${duration} / 60))m \
+$((${duration} % 60))s.  See ${build_log} for details."
+    fi
+    echo "** Successfully built ${what} image in $((${duration} / 60))m \
+$((${duration} % 60))s.  See ${build_log} for details."
+}
+
+docker_run() {
+    local node=${1}
+    local image_name=${2}
+    local ports_option=${3}
+
+    local expose_ports=""
+    if [[ -n ${ports_option} ]]; then
+        expose_ports="-P"
+        for expose_port in ${ports_option//,/ }; do
+            expose_ports="${expose_ports} --expose ${expose_port}"
+        done
+    fi
+
+    # Invoke docker-run. We need privileged mode to be able to run iptables
+    # and mount FUSE filesystems inside the container.  We also need it to
+    # run iptables inside the container.
+    must_do -v docker run --privileged \
+        -d -t -h "${node}" --network ducknet "${expose_ports}" \
+        --memory=${docker_run_memory_limit} --memory-swappiness=1 \
+        -v "${ignite_dir}:/opt/ignite-dev" --name "${node}" -- "${image_name}"
+}
+
+setup_custom_ducktape() {
+    local custom_ducktape="${1}"
+    local image_name="${2}"
+
+    [[ -f "${custom_ducktape}/ducktape/__init__.py" ]] || \
+        die "You must supply a valid ducktape directory to --custom-ducktape"
+    docker_run ducker01 "${image_name}"
+    local running_container="$(docker ps -f=network=ducknet -q)"
+    must_do -v -o docker cp "${custom_ducktape}" "${running_container}:/opt/ducktape"
+    docker exec --user=root ducker01 bash -c 'set -x && cd /opt/ignite-dev/tests && sudo python ./setup.py develop install && cd /opt/ducktape && sudo python ./setup.py develop install'
+    [[ $? -ne 0 ]] && die "failed to install the new ducktape."
+    must_do -v -o docker commit ducker01 "${image_name}"
+    must_do -v docker kill "${running_container}"
+    must_do -v docker rm ducker01
+}
+
+ducker_up() {
+    require_commands docker
+    while [[ $# -ge 1 ]]; do
+        case "${1}" in
+            -C|--custom-ducktape) set_once custom_ducktape "${2}" "the custom ducktape directory"; shift 2;;
+            -f|--force) force=1; shift;;
+            -n|--num-nodes) set_once num_nodes "${2}" "number of nodes"; shift 2;;
+            -j|--jdk) set_once jdk_version "${2}" "the OpenJDK base image"; shift 2;;
+            -e|--expose-ports) set_once expose_ports "${2}" "the ports to expose"; shift 2;;
+            *) set_once image_name "${1}" "docker image name"; shift;;
+        esac
+    done
+    [[ -n "${num_nodes}" ]] || num_nodes="${default_num_nodes}"
+    [[ -n "${jdk_version}" ]] || jdk_version="${default_jdk}"
+    [[ -n "${image_name}" ]] || image_name="${default_image_name}-${jdk_version/:/-}"
+    [[ "${num_nodes}" =~ ^-?[0-9]+$ ]] || \
+        die "ducker_up: the number of nodes must be an integer."
+    [[ "${num_nodes}" -gt 0 ]] || die "ducker_up: the number of nodes must be greater than 0."
+    if [[ "${num_nodes}" -lt 2 ]]; then
+        if [[ "${force}" -ne 1 ]]; then
+            echo "ducker_up: It is recommended to run at least 2 nodes, since ducker01 is only \
+used to run ducktape itself.  If you want to do it anyway, you can use --force to attempt to \
+use only ${num_nodes}."
+            exit 1
+        fi
+    fi
+
+    docker ps >/dev/null || die "ducker_up: failed to run docker.  Please check that the daemon is started."
+
+    ducker_build "${image_name}"
+
+    docker inspect --format='{{.Config.Labels}}' --type=image "${image_name}" | grep -q 'ducker.type'
+    local docker_status=${PIPESTATUS[0]}
+    local grep_status=${PIPESTATUS[1]}
+    [[ "${docker_status}" -eq 0 ]] || die "ducker_up: failed to inspect image ${image_name}.  \
+Please check that it exists."
+    if [[ "${grep_status}" -ne 0 ]]; then
+        if [[ "${force}" -ne 1 ]]; then
+            echo "ducker_up: ${image_name} does not appear to be a ducker image.  It lacks the \
+ducker.type label.  If you think this is a mistake, you can use --force to attempt to bring \
+it up anyway."
+            exit 1
+        fi
+    fi
+    local running_containers="$(docker ps -f=network=ducknet -q)"
+    local num_running_containers=$(count ${running_containers})
+    if [[ ${num_running_containers} -gt 0 ]]; then
+        die "ducker_up: there are ${num_running_containers} ducker containers \
+running already.  Use ducker down to bring down these containers before \
+attempting to start new ones."
+    fi
+
+    echo "ducker_up: Bringing up ${image_name} with ${num_nodes} nodes..."
+    if docker network inspect ducknet &>/dev/null; then
+        must_do -v docker network rm ducknet
+    fi
+    must_do -v docker network create ducknet
+    if [[ -n "${custom_ducktape}" ]]; then
+        setup_custom_ducktape "${custom_ducktape}" "${image_name}"
+    fi
+    for n in $(seq -f %02g 1 ${num_nodes}); do
+        local node="ducker${n}"
+        docker_run "${node}" "${image_name}" "${expose_ports}"
+    done
+    mkdir -p "${ducker_dir}/build"
+    exec 3<> "${ducker_dir}/build/node_hosts"
+    for n in $(seq -f %02g 1 ${num_nodes}); do
+        local node="ducker${n}"
+        docker exec --user=root "${node}" grep "${node}" /etc/hosts >&3
+        [[ $? -ne 0 ]] && die "failed to find the /etc/hosts entry for ${node}"
+    done
+    exec 3>&-
+    for n in $(seq -f %02g 1 ${num_nodes}); do
+        local node="ducker${n}"
+        docker exec --user=root "${node}" \
+            bash -c "grep -v ${node} /opt/ignite-dev/tests/docker/build/node_hosts >> /etc/hosts"
+        [[ $? -ne 0 ]] && die "failed to append to the /etc/hosts file on ${node}"
+    done
+
+    echo "ducker_up: added the latest entries to /etc/hosts on each node."
+    generate_cluster_json_file "${num_nodes}" "${ducker_dir}/build/cluster.json"
+    echo "ducker_up: successfully wrote ${ducker_dir}/build/cluster.json"
+    echo "** ducker_up: successfully brought up ${num_nodes} nodes."
+}
+
+# Generate the cluster.json file used by ducktape to identify cluster nodes.
+#
+# $1: The number of cluster nodes.
+# $2: The path to write the cluster.json file to.
+generate_cluster_json_file() {
+    local num_nodes="${1}"
+    local path="${2}"
+    exec 3<> "${path}"
+cat<<EOF >&3
+{
+  "_comment": [
+    "Licensed to the Apache Software Foundation (ASF) under one or more",
+    "contributor license agreements.  See the NOTICE file distributed with",
+    "this work for additional information regarding copyright ownership.",
+    "The ASF licenses this file to You 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."
+  ],
+  "nodes": [
+EOF
+    for n in $(seq 2 ${num_nodes}); do
+      if [[ ${n} -eq ${num_nodes} ]]; then
+        suffix=""
+      else
+        suffix=","
+      fi
+      local node=$(printf ducker%02d ${n})
+cat<<EOF >&3
+    {
+      "externally_routable_ip": "${node}",
+      "ssh_config": {
+        "host": "${node}",
+        "hostname": "${node}",
+        "identityfile": "/home/ducker/.ssh/id_rsa",
+        "password": "",
+        "port": 22,
+        "user": "ducker"
+      }
+    }${suffix}
+EOF
+    done
+cat<<EOF >&3
+  ]
+}
+EOF
+    exec 3>&-
+}
+
+ducker_test() {
+    require_commands docker
+    docker inspect ducker01 &>/dev/null || \
+        die "ducker_test: the ducker01 instance appears to be down. Did you run 'ducker up'?"
+    [[ $# -lt 1 ]] && \
+        die "ducker_test: you must supply at least one system test to run. Type --help for help."
+    local args=""
+    local ignite_test=0
+    for arg in "${@}"; do
+        local regex=".*\/ignitetest\/(.*)"
+        if [[ $arg =~ $regex ]]; then
+            local kpath=${BASH_REMATCH[1]}
+            args="${args} ./tests/ignitetest/${kpath}"
+        else
+            args="${args} ${arg}"
+        fi
+    done
+    must_pushd "${ignite_dir}"
+    (test -f ./gradlew || gradle) && ./gradlew systemTestLibs
+    must_popd
+    cmd="cd /opt/ignite-dev && ducktape --cluster-file /opt/ignite-dev/tests/docker/build/cluster.json $args"
+    echo "docker exec ducker01 bash -c \"${cmd}\""
+    exec docker exec --user=ducker ducker01 bash -c "${cmd}"
+}
+
+ducker_ssh() {
+    require_commands docker
+    [[ $# -eq 0 ]] && die "ducker_ssh: Please specify a container name to log into. \
+Currently active containers: $(echo_running_container_names)"
+    local node_info="${1}"
+    shift
+    local guest_command="$*"
+    local user_name="ducker"
+    if [[ "${node_info}" =~ @ ]]; then
+        user_name="${node_info%%@*}"
+        local node_name="${node_info##*@}"
+    else
+        local node_name="${node_info}"
+    fi
+    local docker_flags=""
+    if [[ -z "${guest_command}" ]]; then
+        local docker_flags="${docker_flags} -t"
+        local guest_command_prefix=""
+        guest_command=bash
+    else
+        local guest_command_prefix="bash -c"
+    fi
+    if [[ "${node_name}" == "all" ]]; then
+        local nodes=$(echo_running_container_names)
+        [[ "${nodes}" == "(none)" ]] && die "ducker_ssh: can't locate any running ducker nodes."
+        for node in ${nodes}; do
+            docker exec --user=${user_name} -i ${docker_flags} "${node}" \
+                ${guest_command_prefix} "${guest_command}" || die "docker exec ${node} failed"
+        done
+    else
+        docker inspect --type=container -- "${node_name}" &>/dev/null || \
+            die "ducker_ssh: can't locate node ${node_name}. Currently running nodes: \
+$(echo_running_container_names)"
+        exec docker exec --user=${user_name} -i ${docker_flags} "${node_name}" \
+            ${guest_command_prefix} "${guest_command}"
+    fi
+}
+
+# Echo all the running Ducker container names, or (none) if there are no running Ducker containers.
+echo_running_container_names() {
+    node_names="$(docker ps -f=network=ducknet -q --format '{{.Names}}' | sort)"
+    if [[ -z "${node_names}" ]]; then
+        echo "(none)"
+    else
+        echo ${node_names//$'\n'/ }
+    fi
+}
+
+ducker_down() {
+    require_commands docker
+    local verbose=1
+    local force_str=""
+    while [[ $# -ge 1 ]]; do
+        case "${1}" in
+            -q|--quiet) verbose=0; shift;;
+            -f|--force) force_str="-f"; shift;;
+            *) die "ducker_down: unexpected command-line argument ${1}";;
+        esac
+    done
+    local running_containers
+    running_containers="$(docker ps -f=network=ducknet -q)"
+    [[ $? -eq 0 ]]  || die "ducker_down: docker command failed.  Is the docker daemon running?"
+    running_containers=${running_containers//$'\n'/ }
+    local all_containers="$(docker ps -a -f=network=ducknet -q)"
+    all_containers=${all_containers//$'\n'/ }
+    if [[ -z "${all_containers}" ]]; then
+        maybe_echo "${verbose}" "No ducker containers found."
+        return
+    fi
+    verbose_flag=""
+    if [[ ${verbose} == 1 ]]; then
+        verbose_flag="-v"
+    fi
+    if [[ -n "${running_containers}" ]]; then
+        must_do ${verbose_flag} docker kill "${running_containers}"
+    fi
+    must_do ${verbose_flag} docker rm ${force_str} "${all_containers}"
+    must_do ${verbose_flag} -o rm -f -- "${ducker_dir}/build/node_hosts" "${ducker_dir}/build/cluster.json"
+    if docker network inspect ducknet &>/dev/null; then
+        must_do -v docker network rm ducknet
+    fi
+    maybe_echo "${verbose}" "ducker_down: removed $(count ${all_containers}) containers."
+}
+
+ducker_purge() {
+    require_commands docker
+    local force_str=""
+    while [[ $# -ge 1 ]]; do
+        case "${1}" in
+            -f|--force) force_str="-f"; shift;;
+            *) die "ducker_purge: unknown argument ${1}";;
+        esac
+    done
+    echo "** ducker_purge: attempting to locate ducker images to purge"
+    local images
+    images=$(docker images -q -a -f label=ducker.creator)
+    [[ $? -ne 0 ]] && die "docker images command failed"
+    images=${images//$'\n'/ }
+    declare -a purge_images=()
+    if [[ -z "${images}" ]]; then
+        echo "** ducker_purge: no images found to purge."
+        exit 0
+    fi
+    echo "** ducker_purge: images to delete:"
+    for image in ${images}; do
+        echo -n "${image} "
+        docker inspect --format='{{.Config.Labels}} {{.Created}}' --type=image "${image}"
+        [[ $? -ne 0 ]] && die "docker inspect ${image} failed"
+    done
+    ask_yes_no "Delete these docker images? [y/n]"
+    [[ "${_return}" -eq 0 ]] && exit 0
+    must_do -v -o docker rmi ${force_str} ${images}
+}
+
+# Parse command-line arguments
+[[ $# -lt 1 ]] && usage 0
+# Display the help text if -h or --help appears in the command line
+for arg in ${@}; do
+    case "${arg}" in
+        -h|--help) usage 0;;
+        --) break;;
+        *);;
+    esac
+done
+action="${1}"
+shift
+case "${action}" in
+    help) usage 0;;
+
+    up|test|ssh|down|purge)
+        ducker_${action} "${@}"; exit 0;;
+
+    *)  echo "Unknown command '${action}'.  Type '${script_path} --help' for usage information."
+        exit 1;;
+esac
diff --git a/tests/docker/run_tests.sh b/tests/docker/run_tests.sh
new file mode 100755
index 0000000..09c6797
--- /dev/null
+++ b/tests/docker/run_tests.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+IGNITE_NUM_CONTAINERS=${IGNITE_NUM_CONTAINERS:-14}
+TC_PATHS=${TC_PATHS:-./ignitetest/}
+
+die() {
+    echo $@
+    exit 1
+}
+
+if ${SCRIPT_DIR}/ducker-ignite ssh | grep -q '(none)'; then
+    ${SCRIPT_DIR}/ducker-ignite up -n "${IGNITE_NUM_CONTAINERS}" || die "ducker-ak up failed"
+fi
+${SCRIPT_DIR}/ducker-ignite test ${TC_PATHS} ${_DUCKTAPE_OPTIONS} || die "ducker-ak test failed"
diff --git a/tests/docker/ssh-config b/tests/docker/ssh-config
new file mode 100644
index 0000000..1f87417
--- /dev/null
+++ b/tests/docker/ssh-config
@@ -0,0 +1,21 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+Host *
+  ControlMaster auto
+  ControlPath ~/.ssh/master-%r@%h:%p
+  StrictHostKeyChecking no
+  ConnectTimeout=10
+  IdentityFile ~/.ssh/id_rsa
diff --git a/tests/docker/ssh/authorized_keys b/tests/docker/ssh/authorized_keys
new file mode 100644
index 0000000..9f9da1f
--- /dev/null
+++ b/tests/docker/ssh/authorized_keys
@@ -0,0 +1,15 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0qDT9kEPWc8JQ53b4KnT/ZJOLwb+3c//jpLW/2ofjDyIsPW4FohLpicfouch/zsRpN4G38lua+2BsGls9sMIZc6PXY2L+NIGCkqEMdCoU1Ym8SMtyJklfzp3m/0PeK9s2dLlR3PFRYvyFA4btQK5hkbYDNZPzf4airvzdRzLkrFf81+RemaMI2EtONwJRcbLViPaTXVKJdbFwJTJ1u7yu9wDYWHKBMA92mHTQeP6bhVYCqxJn3to/RfZYd+sHw6mfxVg5OrAlUOYpSV4pDNCAsIHdtZ56V8NQlJL6NJ2vzzSSYUwLMqe88fhrC8yYHoxC07QPy1EdkSTHdohAicyT root@knode01.knw
diff --git a/tests/docker/ssh/config b/tests/docker/ssh/config
new file mode 100644
index 0000000..1f87417
--- /dev/null
+++ b/tests/docker/ssh/config
@@ -0,0 +1,21 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+Host *
+  ControlMaster auto
+  ControlPath ~/.ssh/master-%r@%h:%p
+  StrictHostKeyChecking no
+  ConnectTimeout=10
+  IdentityFile ~/.ssh/id_rsa
diff --git a/tests/docker/ssh/id_rsa b/tests/docker/ssh/id_rsa
new file mode 100644
index 0000000..276e07b
--- /dev/null
+++ b/tests/docker/ssh/id_rsa
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEAtKg0/ZBD1nPCUOd2+Cp0/2STi8G/t3P/46S1v9qH4w8iLD1u
+BaIS6YnH6LnIf87EaTeBt/JbmvtgbBpbPbDCGXOj12Ni/jSBgpKhDHQqFNWJvEjL
+ciZJX86d5v9D3ivbNnS5UdzxUWL8hQOG7UCuYZG2AzWT83+Goq783Ucy5KxX/Nfk
+XpmjCNhLTjcCUXGy1Yj2k11SiXWxcCUydbu8rvcA2FhygTAPdph00Hj+m4VWAqsS
+Z97aP0X2WHfrB8Opn8VYOTqwJVDmKUleKQzQgLCB3bWeelfDUJSS+jSdr880kmFM
+CzKnvPH4awvMmB6MQtO0D8tRHZEkx3aIQInMkwIDAQABAoIBAQCz6EMFNNLp0NP1
+X9yRXS6wW4e4CRWUazesiw3YZpcmnp6IchCMGZA99FEZyVILPW1J3tYWyotBdw7Z
++RFeCRXy5L+IMtiVkNJcpwss7M4ve0w0LkY0gj5V49xJ+3Gp4gDnZSxcguvrAem5
+yP5obR572fDpl0SknB4HCr6U2l+rauzrLyevy5eeDT/vmXbuM1cdHpNIXmmElz4L
+t31n+exQRn6tP1h516iXbcYbopxDgdv2qKGAqzWKE6TyWpzF5x7kjOEYt0bZ5QO3
+Lwh7AAqE/3mwxlYwng1L4WAT7RtcP19W+9JDIc7ENInMGxq6q46p1S3IPZsf1cj/
+aAJ9q3LBAoGBAOVJr0+WkR786n3BuswpGQWBgVxfai4y9Lf90vuGKawdQUzXv0/c
+EB/CFqP/dIsquukA8PfzjNMyTNmEHXi4Sf16H8Rg4EGhIYMEqIQojx1t/yLLm0aU
+YPEvW/02Umtlg3pJw9fQAAzFVqCasw2E2lUdAUkydGRwDUJZmv2/b3NzAoGBAMm0
+Jo7Et7ochH8Vku6uA+hG+RdwlKFm5JA7/Ci3DOdQ1zmJNrvBBFQLo7AjA4iSCoBd
+s9+y0nrSPcF4pM3l6ghLheaqbnIi2HqIMH9mjDbrOZiWvbnjvjpOketgNX8vV3Ye
+GUkSjoNcmvRmdsICmUjeML8bGOmq4zF9W/GIfTphAoGBAKGRo8R8f/SLGh3VtvCI
+gUY89NAHuEWnyIQii1qMNq8+yjYAzaHTm1UVqmiT6SbrzFvGOwcuCu0Dw91+2Fmp
+2xGPzfTOoxf8GCY/0ROXlQmS6jc1rEw24Hzz92ldrwRYuyYf9q4Ltw1IvXtcp5F+
+LW/OiYpv0E66Gs3HYI0wKbP7AoGBAJMZWeFW37LQJ2TTJAQDToAwemq4xPxsoJX7
+2SsMTFHKKBwi0JLe8jwk/OxwrJwF/bieHZcvv8ao2zbkuDQcz6/a/D074C5G8V9z
+QQM4k1td8vQwQw91Yv782/gvgvRNX1iaHNCowtxURgGlVEirQoTc3eoRZfrLkMM/
+7DTa2JEhAoGACEu3zHJ1sgyeOEgLArUJXlQM30A/ulMrnCd4MEyIE+ReyWAUevUQ
+0lYdVNva0/W4C5e2lUOJL41jjIPLqI7tcFR2PZE6n0xTTkxNH5W2u1WpFeKjx+O3
+czv7Bt6wYyLHIMy1JEqAQ7pw1mtJ5s76UDvXUhciF+DU2pWYc6APKR0=
+-----END RSA PRIVATE KEY-----
diff --git a/tests/docker/ssh/id_rsa.pub b/tests/docker/ssh/id_rsa.pub
new file mode 100644
index 0000000..76e8f5f
--- /dev/null
+++ b/tests/docker/ssh/id_rsa.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0qDT9kEPWc8JQ53b4KnT/ZJOLwb+3c//jpLW/2ofjDyIsPW4FohLpicfouch/zsRpN4G38lua+2BsGls9sMIZc6PXY2L+NIGCkqEMdCoU1Ym8SMtyJklfzp3m/0PeK9s2dLlR3PFRYvyFA4btQK5hkbYDNZPzf4airvzdRzLkrFf81+RemaMI2EtONwJRcbLViPaTXVKJdbFwJTJ1u7yu9wDYWHKBMA92mHTQeP6bhVYCqxJn3to/RfZYd+sHw6mfxVg5OrAlUOYpSV4pDNCAsIHdtZ56V8NQlJL6NJ2vzzSSYUwLMqe88fhrC8yYHoxC07QPy1EdkSTHdohAicyT root@knode01.knw
diff --git a/tests/ignitetest/__init__.py b/tests/ignitetest/__init__.py
new file mode 100644
index 0000000..c5862fe
--- /dev/null
+++ b/tests/ignitetest/__init__.py
@@ -0,0 +1,25 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+# This determines the version of kafkatest that can be published to PyPi and installed with pip
+#
+# Note that in development, this version name can't follow Kafka's convention of having a trailing "-SNAPSHOT"
+# due to python version naming restrictions, which are enforced by python packaging tools
+# (see  https://www.python.org/dev/peps/pep-0440/)
+#
+# Instead, in development branches, the version should have a suffix of the form ".devN"
+#
+# For example, when Ignite is at version 2.9.0-SNAPSHOT, this should be something like "2.9.0.dev0"
+__version__ = '2.9.0.dev0'
diff --git a/tests/ignitetest/utils/__init__.py b/tests/ignitetest/utils/__init__.py
new file mode 100644
index 0000000..d753d8e
--- /dev/null
+++ b/tests/ignitetest/utils/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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 util import ignitetest_version, is_int, is_int_with_prefix, node_is_reachable, validate_delivery
diff --git a/tests/ignitetest/utils/remote_account.py b/tests/ignitetest/utils/remote_account.py
new file mode 100644
index 0000000..e838a96
--- /dev/null
+++ b/tests/ignitetest/utils/remote_account.py
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+
+def file_exists(node, file):
+    """Quick and dirty check for existence of remote file."""
+    try:
+        node.account.ssh("cat " + file, allow_fail=False)
+        return True
+    except:
+        return False
+
+
+def path_exists(node, path):
+    """Quick and dirty check for existence of remote path."""
+    try:
+        node.account.ssh("ls " + path, allow_fail=False)
+        return True
+    except:
+        return False
+
+
+def line_count(node, file):
+    """Return the line count of file on node"""
+    out = [line for line in node.account.ssh_capture("wc -l %s" % file)]
+    if len(out) != 1:
+        raise Exception("Expected single line of output from wc -l")
+
+    return int(out[0].strip().split(" ")[0])
diff --git a/tests/ignitetest/utils/util.py b/tests/ignitetest/utils/util.py
new file mode 100644
index 0000000..0bb22eb
--- /dev/null
+++ b/tests/ignitetest/utils/util.py
@@ -0,0 +1,138 @@
+# Copyright 2015 Confluent Inc.
+#
+# 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 ignitetest import __version__ as __ignitetest_version__
+
+import math
+import re
+import time
+
+
+def ignitetest_version():
+    """Return string representation of current ducktape version."""
+    return __ignitetest_version__
+
+
+def is_int(msg):
+    """Method used to check whether the given message is an integer
+
+    return int or raises an exception if message is not an integer
+    """
+    try:
+        return int(msg)
+    except ValueError:
+        raise Exception("Unexpected message format (expected an integer). Message: %s" % (msg))
+
+
+def is_int_with_prefix(msg):
+    """
+    Method used check whether the given message is of format 'integer_prefix'.'integer_value'
+
+    :param msg: message to validate
+    :return: msg or raises an exception is a message is of wrong format
+    """
+    try:
+        parts = msg.split(".")
+        if len(parts) != 2:
+            raise Exception("Unexpected message format. Message should be of format: integer "
+                            "prefix dot integer value. Message: %s" % (msg))
+        int(parts[0])
+        int(parts[1])
+        return msg
+    except ValueError:
+        raise Exception("Unexpected message format. Message should be of format: integer "
+                        "prefix dot integer value, but one of the two parts (before or after dot) "
+                        "are not integers. Message: %s" % (msg))
+
+
+def node_is_reachable(src_node, dst_node):
+    """
+    Returns true if a node is unreachable from another node.
+
+    :param src_node:        The source node to check from reachability from.
+    :param dst_node:        The destination node to check for reachability to.
+    :return:                True only if dst is reachable from src.
+    """
+    return 0 == src_node.account.ssh("nc -w 3 -z %s 22" % dst_node.account.hostname, allow_fail=True)
+
+
+def annotate_missing_msgs(missing, acked, consumed, msg):
+    missing_list = list(missing)
+    msg += "%s acked message did not make it to the Consumer. They are: " %\
+        len(missing_list)
+    if len(missing_list) < 20:
+        msg += str(missing_list) + ". "
+    else:
+        msg += ", ".join(str(m) for m in missing_list[:20])
+        msg += "...plus %s more. Total Acked: %s, Total Consumed: %s. " \
+            % (len(missing_list) - 20, len(set(acked)), len(set(consumed)))
+    return msg
+
+
+def annotate_data_lost(data_lost, msg, number_validated):
+    print_limit = 10
+    if len(data_lost) > 0:
+        msg += "The first %s missing messages were validated to ensure they are in Kafka's data files. " \
+            "%s were missing. This suggests data loss. Here are some of the messages not found in the data files: %s\n" \
+            % (number_validated, len(data_lost), str(data_lost[0:print_limit]) if len(data_lost) > print_limit else str(data_lost))
+    else:
+        msg += "We validated that the first %s of these missing messages correctly made it into Kafka's data files. " \
+            "This suggests they were lost on their way to the consumer." % number_validated
+    return msg
+
+
+def validate_delivery(acked, consumed, idempotence_enabled=False, check_lost_data=None, may_truncate_acked_records=False):
+    """Check that each acked message was consumed."""
+    success = True
+    msg = ""
+
+    # Correctness of the set difference operation depends on using equivalent
+    # message_validators in producer and consumer
+    missing = set(acked) - set(consumed)
+    
+    # Were all acked messages consumed?
+    if len(missing) > 0:
+        msg = annotate_missing_msgs(missing, acked, consumed, msg)
+        
+        # Did we miss anything due to data loss?
+        if check_lost_data:
+            max_truncate_count = 100 if may_truncate_acked_records else 0
+            max_validate_count = max(1000, max_truncate_count)
+
+            to_validate = list(missing)[0:min(len(missing), max_validate_count)]
+            data_lost = check_lost_data(to_validate)
+
+            # With older versions of message format before KIP-101, data loss could occur due to truncation.
+            # These records won't be in the data logs. Tolerate limited data loss for this case.
+            if len(missing) < max_truncate_count and len(data_lost) == len(missing):
+               msg += "The %s missing messages were not present in Kafka's data files. This suggests data loss " \
+                   "due to truncation, which is possible with older message formats and hence are ignored " \
+                   "by this test. The messages lost: %s\n" % (len(data_lost), str(data_lost))
+            else:
+                msg = annotate_data_lost(data_lost, msg, len(to_validate))
+                success = False
+        else:
+            success = False
+
+    # Are there duplicates?
+    if len(set(consumed)) != len(consumed):
+        num_duplicates = abs(len(set(consumed)) - len(consumed))
+
+        if idempotence_enabled:
+            success = False
+            msg += "Detected %d duplicates even though idempotence was enabled.\n" % num_duplicates
+        else:
+            msg += "(There are also %d duplicate messages in the log - but that is an acceptable outcome)\n" % num_duplicates
+
+    return success, msg
diff --git a/tests/ignitetest/version.py b/tests/ignitetest/version.py
new file mode 100644
index 0000000..9509909
--- /dev/null
+++ b/tests/ignitetest/version.py
@@ -0,0 +1,140 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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 distutils.version import LooseVersion
+from ignitetest.utils import ignitetest_version
+
+
+class KafkaVersion(LooseVersion):
+    """Container for kafka versions which makes versions simple to compare.
+
+    distutils.version.LooseVersion (and StrictVersion) has robust comparison and ordering logic.
+
+    Example:
+
+        v10 = KafkaVersion("0.10.0")
+        v9 = KafkaVersion("0.9.0.1")
+        assert v10 > v9  # assertion passes!
+    """
+    def __init__(self, version_string):
+        self.is_dev = (version_string.lower() == "dev")
+        if self.is_dev:
+            version_string = kafkatest_version()
+
+            # Drop dev suffix if present
+            dev_suffix_index = version_string.find(".dev")
+            if dev_suffix_index >= 0:
+                version_string = version_string[:dev_suffix_index]
+
+        # Don't use the form super.(...).__init__(...) because
+        # LooseVersion is an "old style" python class
+        LooseVersion.__init__(self, version_string)
+
+    def __str__(self):
+        if self.is_dev:
+            return "dev"
+        else:
+            return LooseVersion.__str__(self)
+
+    def supports_named_listeners(self):
+        return self >= V_0_10_2_0
+
+    def topic_command_supports_bootstrap_server(self):
+        return self >= V_2_3_0
+
+def get_version(node=None):
+    """Return the version attached to the given node.
+    Default to DEV_BRANCH if node or node.version is undefined (aka None)
+    """
+    if node is not None and hasattr(node, "version") and node.version is not None:
+        return node.version
+    else:
+        return DEV_BRANCH
+
+DEV_BRANCH = KafkaVersion("dev")
+DEV_VERSION = KafkaVersion("2.6.0-SNAPSHOT")
+
+# 0.8.2.x versions
+V_0_8_2_1 = KafkaVersion("0.8.2.1")
+V_0_8_2_2 = KafkaVersion("0.8.2.2")
+LATEST_0_8_2 = V_0_8_2_2
+
+# 0.9.0.x versions
+V_0_9_0_0 = KafkaVersion("0.9.0.0")
+V_0_9_0_1 = KafkaVersion("0.9.0.1")
+LATEST_0_9 = V_0_9_0_1
+
+# 0.10.0.x versions
+V_0_10_0_0 = KafkaVersion("0.10.0.0")
+V_0_10_0_1 = KafkaVersion("0.10.0.1")
+LATEST_0_10_0 = V_0_10_0_1
+
+# 0.10.1.x versions
+V_0_10_1_0 = KafkaVersion("0.10.1.0")
+V_0_10_1_1 = KafkaVersion("0.10.1.1")
+LATEST_0_10_1 = V_0_10_1_1
+
+# 0.10.2.x versions
+V_0_10_2_0 = KafkaVersion("0.10.2.0")
+V_0_10_2_1 = KafkaVersion("0.10.2.1")
+V_0_10_2_2 = KafkaVersion("0.10.2.2")
+LATEST_0_10_2 = V_0_10_2_2
+
+LATEST_0_10 = LATEST_0_10_2
+
+# 0.11.0.x versions
+V_0_11_0_0 = KafkaVersion("0.11.0.0")
+V_0_11_0_1 = KafkaVersion("0.11.0.1")
+V_0_11_0_2 = KafkaVersion("0.11.0.2")
+V_0_11_0_3 = KafkaVersion("0.11.0.3")
+LATEST_0_11_0 = V_0_11_0_3
+LATEST_0_11 = LATEST_0_11_0
+
+# 1.0.x versions
+V_1_0_0 = KafkaVersion("1.0.0")
+V_1_0_1 = KafkaVersion("1.0.1")
+V_1_0_2 = KafkaVersion("1.0.2")
+LATEST_1_0 = V_1_0_2
+
+# 1.1.x versions
+V_1_1_0 = KafkaVersion("1.1.0")
+V_1_1_1 = KafkaVersion("1.1.1")
+LATEST_1_1 = V_1_1_1
+
+# 2.0.x versions
+V_2_0_0 = KafkaVersion("2.0.0")
+V_2_0_1 = KafkaVersion("2.0.1")
+LATEST_2_0 = V_2_0_1
+
+# 2.1.x versions
+V_2_1_0 = KafkaVersion("2.1.0")
+V_2_1_1 = KafkaVersion("2.1.1")
+LATEST_2_1 = V_2_1_1
+
+# 2.2.x versions
+V_2_2_0 = KafkaVersion("2.2.0")
+V_2_2_1 = KafkaVersion("2.2.1")
+V_2_2_2 = KafkaVersion("2.2.2")
+LATEST_2_2 = V_2_2_2
+
+# 2.3.x versions
+V_2_3_0 = KafkaVersion("2.3.0")
+V_2_3_1 = KafkaVersion("2.3.1")
+LATEST_2_3 = V_2_3_1
+
+# 2.4.x versions
+V_2_4_0 = KafkaVersion("2.4.0")
+LATEST_2_4 = V_2_4_0
diff --git a/tests/setup.cfg b/tests/setup.cfg
new file mode 100644
index 0000000..974d5bb
--- /dev/null
+++ b/tests/setup.cfg
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+# pytest configuration (can also be defined in tox.ini or pytest.ini file)
+#
+# This file defines naming convention and root search directory for autodiscovery of
+# pytest unit tests for the system test service classes.
+#
+# To ease possible confusion, 'check' instead of 'test' as a prefix for unit tests, since
+# many system test files, classes, and methods have 'test' somewhere in the name
+[pytest]
+testpaths=unit
+python_files=check_*.py
+python_classes=Check
+python_functions=check_*
+
+# don't search inside any resources directory for unit tests
+norecursedirs = resources
diff --git a/tests/setup.py b/tests/setup.py
new file mode 100644
index 0000000..3e47716
--- /dev/null
+++ b/tests/setup.py
@@ -0,0 +1,57 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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 re
+import sys
+from setuptools import find_packages, setup
+from setuptools.command.test import test as TestCommand
+
+version = ''
+with open('ignitetest/__init__.py', 'r') as fd:
+    version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE).group(1)
+
+
+class PyTest(TestCommand):
+    user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")]
+
+    def initialize_options(self):
+        TestCommand.initialize_options(self)
+        self.pytest_args = []
+
+    def finalize_options(self):
+        TestCommand.finalize_options(self)
+        self.test_args = []
+        self.test_suite = True
+
+    def run_tests(self):
+        # import here, cause outside the eggs aren't loaded
+        import pytest
+        print(self.pytest_args)
+        errno = pytest.main(self.pytest_args)
+        sys.exit(errno)
+
+# Note: when changing the version of ducktape, also revise tests/docker/Dockerfile
+setup(name="ignitetest",
+      version=version,
+      description="Apache Ignite System Tests",
+      author="Apache Ignite",
+      platforms=["any"], 
+      license="apache2.0",
+      packages=find_packages(),
+      include_package_data=True,
+      install_requires=["ducktape==0.7.6", "requests==2.20.0"],
+      tests_require=["pytest", "mock"],
+      cmdclass={'test': PyTest}
+)