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

dataroaring pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new b9496b2a8f [feature](docker) regression test support run suite in 
docker (#24508)
b9496b2a8f is described below

commit b9496b2a8f8bc8f0b3b7bcac3af53004def55a9b
Author: yujun <[email protected]>
AuthorDate: Sun Sep 24 21:44:16 2023 +0800

    [feature](docker) regression test support run suite in docker (#24508)
---
 docker/runtime/doris-compose/Dockerfile            |   2 +-
 docker/runtime/doris-compose/cluster.py            |  51 ++-
 docker/runtime/doris-compose/command.py            | 162 +++++++--
 docker/runtime/doris-compose/database.py           |  38 ++-
 docker/runtime/doris-compose/doris-compose.py      |  37 ++-
 docker/runtime/doris-compose/resource/init_be.sh   |   7 +-
 docker/runtime/doris-compose/resource/init_fe.sh   |  44 ++-
 docker/runtime/doris-compose/utils.py              |  35 +-
 regression-test/conf/regression-conf.groovy        |   7 +
 .../org/apache/doris/regression/Config.groovy      |  39 ++-
 .../apache/doris/regression/ConfigOptions.groovy   |  11 +
 .../org/apache/doris/regression/suite/Suite.groovy |  34 ++
 .../doris/regression/suite/SuiteCluster.groovy     | 363 +++++++++++++++++++++
 .../suites/chaos/test_docker_example.groovy        |  53 +++
 14 files changed, 797 insertions(+), 86 deletions(-)

diff --git a/docker/runtime/doris-compose/Dockerfile 
b/docker/runtime/doris-compose/Dockerfile
index 8f5d7e3376..b930b10668 100644
--- a/docker/runtime/doris-compose/Dockerfile
+++ b/docker/runtime/doris-compose/Dockerfile
@@ -25,6 +25,6 @@ ENV JAVA_HOME="/usr/local/openjdk-8/"
 ADD output /opt/apache-doris/
 
 RUN apt-get update && \
-    apt-get install -y default-mysql-client python && \
+    apt-get install -y default-mysql-client python lsof && \
     apt-get clean
 
diff --git a/docker/runtime/doris-compose/cluster.py 
b/docker/runtime/doris-compose/cluster.py
index 2556809a3c..6c6ccf3953 100644
--- a/docker/runtime/doris-compose/cluster.py
+++ b/docker/runtime/doris-compose/cluster.py
@@ -91,8 +91,8 @@ class Group(object):
         self.nodes = {}  # id : NodeMeta
         self.next_id = 1
 
-    def add(self, id, image):
-        assert image
+    def add(self, id, node_meta):
+        assert node_meta.image
         if not id:
             id = self.next_id
             self.next_id += 1
@@ -104,7 +104,7 @@ class Group(object):
             raise Exception(
                 "Failed to add {} with id {}, id exceeds {}".format(
                     self.node_type, id, ID_LIMIT))
-        self.nodes[id] = NodeMeta(image)
+        self.nodes[id] = node_meta
 
         return id
 
@@ -147,7 +147,7 @@ class Node(object):
         else:
             raise Exception("Unknown node type {}".format(node_type))
 
-    def init_dir(self):
+    def init_conf(self, config):
         path = self.get_path()
         os.makedirs(path, exist_ok=True)
 
@@ -160,6 +160,12 @@ class Node(object):
                 conf_dir)
             assert not utils.is_dir_empty(conf_dir), "conf directory {} is 
empty, " \
                     "check doris path in image is correct".format(conf_dir)
+            utils.enable_dir_with_rw_perm(conf_dir)
+            if config:
+                with open(os.path.join(conf_dir, self.conf_file_name()),
+                          "a") as f:
+                    for item in config:
+                        f.write(item + "\n")
         for sub_dir in self.expose_sub_dirs():
             os.makedirs(os.path.join(path, sub_dir), exist_ok=True)
 
@@ -169,6 +175,9 @@ class Node(object):
     def is_be(self):
         return self.node_type() == Node.TYPE_BE
 
+    def conf_file_name(self):
+        return self.node_type() + ".conf"
+
     def node_type(self):
         raise Exception("No implemented")
 
@@ -307,22 +316,38 @@ class BE(Node):
     def expose_sub_dirs(self):
         return super().expose_sub_dirs() + ["storage"]
 
+    def init_disk(self, be_disk_num):
+        if not be_disk_num or be_disk_num <= 1:
+            return
+        path = self.get_path()
+        for i in range(1, be_disk_num + 1):
+            os.makedirs("{}/storage/disk{}".format(path, i), exist_ok=True)
+        with open("{}/conf/{}".format(path, self.conf_file_name()), "a") as f:
+            f.write("storage_root_path = " + ";".join([
+                "${{DORIS_HOME}}/storage/disk{}".format(i)
+                for i in range(1, be_disk_num + 1)
+            ]) + "\n")
+
 
 class Cluster(object):
 
-    def __init__(self, name, subnet, image):
+    def __init__(self, name, subnet, image, fe_config, be_config, be_disk_num):
         self.name = name
         self.subnet = subnet
         self.image = image
+        self.fe_config = fe_config
+        self.be_config = be_config
+        self.be_disk_num = be_disk_num
         self.groups = {
             node_type: Group(node_type)
             for node_type in Node.TYPE_ALL
         }
 
     @staticmethod
-    def new(name, image):
+    def new(name, image, fe_config, be_config, be_disk_num):
         subnet = gen_subnet_prefix16()
-        cluster = Cluster(name, subnet, image)
+        cluster = Cluster(name, subnet, image, fe_config, be_config,
+                          be_disk_num)
         os.makedirs(cluster.get_path(), exist_ok=True)
         os.makedirs(get_status_path(name), exist_ok=True)
         return cluster
@@ -393,9 +418,17 @@ class Cluster(object):
         return num
 
     def add(self, node_type, id=None):
-        id = self.get_group(node_type).add(id, self.image)
+        node_meta = NodeMeta(self.image)
+        id = self.get_group(node_type).add(id, node_meta)
         node = self.get_node(node_type, id)
-        node.init_dir()
+        if not os.path.exists(node.get_path()):
+            if node.is_fe():
+                node.init_conf(self.fe_config)
+            elif node.is_be():
+                node.init_conf(self.be_config)
+                node.init_disk(self.be_disk_num)
+            else:
+                node.init_conf([])
         return node
 
     def remove(self, node_type, id):
diff --git a/docker/runtime/doris-compose/command.py 
b/docker/runtime/doris-compose/command.py
index 2fa556d513..adb2d2b0d0 100644
--- a/docker/runtime/doris-compose/command.py
+++ b/docker/runtime/doris-compose/command.py
@@ -24,6 +24,7 @@ import os.path
 import prettytable
 import shutil
 import sys
+import time
 
 LOG = utils.get_logger()
 
@@ -73,6 +74,12 @@ class Command(object):
     def run(self, args):
         raise Exception("No implemented")
 
+    def _add_parser_output_json(self, parser):
+        parser.add_argument("--output-json",
+                            default=False,
+                            action=self._get_parser_bool_action(True),
+                            help="output as json, and don't print log")
+
     def _add_parser_ids_args(self, parser):
         group = parser.add_argument_group("for existing nodes",
                                           "apply to the existing nodes.")
@@ -100,6 +107,7 @@ class SimpleCommand(Command):
         parser = args_parsers.add_parser(self.command, help=help)
         parser.add_argument("NAME", help="Specify cluster name.")
         self._add_parser_ids_args(parser)
+        self._add_parser_output_json(parser)
 
     def run(self, args):
         cluster = CLUSTER.Cluster.load(args.NAME)
@@ -127,6 +135,16 @@ class UpCommand(Command):
                             nargs="?",
                             help="Specify docker image.")
 
+        parser.add_argument(
+            "--wait-timeout",
+            type=int,
+            default=0,
+            help=
+            "Specify wait seconds for upping: 0(default) not wait, > 0 max 
wait seconds, -1 wait unlimited."
+        )
+
+        self._add_parser_output_json(parser)
+
         group1 = parser.add_argument_group("add new nodes",
                                            "add cluster nodes.")
         group1.add_argument(
@@ -137,6 +155,18 @@ class UpCommand(Command):
             "--add-be-num",
             type=int,
             help="Specify add be num, default 3 for a new cluster.")
+        group1.add_argument("--fe-config",
+                            nargs="*",
+                            type=str,
+                            help="Specify fe configs.")
+        group1.add_argument("--be-config",
+                            nargs="*",
+                            type=str,
+                            help="Specify be configs.")
+        group1.add_argument("--be-disk-num",
+                            default=None,
+                            type=int,
+                            help="Specify be disk num, default is 1.")
 
         self._add_parser_ids_args(parser)
 
@@ -163,7 +193,7 @@ class UpCommand(Command):
         except:
             # a new cluster
             if not args.IMAGE:
-                raise Exception("New cluster must specific image")
+                raise Exception("New cluster must specific image") from None
             if args.fe_id != None:
                 args.fe_id = None
                 LOG.warning(
@@ -172,7 +202,9 @@ class UpCommand(Command):
                 args.be_id = None
                 LOG.warning(
                     utils.render_yellow("Ignore --be-id for new cluster"))
-            cluster = CLUSTER.Cluster.new(args.NAME, args.IMAGE)
+            cluster = CLUSTER.Cluster.new(args.NAME, args.IMAGE,
+                                          args.fe_config, args.be_config,
+                                          args.be_disk_num)
             LOG.info("Create new cluster {} succ, cluster path is {}".format(
                 args.NAME, cluster.get_path()))
             if not args.add_fe_num:
@@ -182,14 +214,20 @@ class UpCommand(Command):
 
         _, related_nodes, _ = get_ids_related_nodes(cluster, args.fe_id,
                                                     args.be_id)
+        add_be_ids = []
+        add_fe_ids = []
         if not related_nodes:
             related_nodes = []
         if args.add_fe_num:
             for i in range(args.add_fe_num):
-                related_nodes.append(cluster.add(CLUSTER.Node.TYPE_FE))
+                fe = cluster.add(CLUSTER.Node.TYPE_FE)
+                related_nodes.append(fe)
+                add_fe_ids.append(fe.id)
         if args.add_be_num:
             for i in range(args.add_be_num):
-                related_nodes.append(cluster.add(CLUSTER.Node.TYPE_BE))
+                be = cluster.add(CLUSTER.Node.TYPE_BE)
+                related_nodes.append(be)
+                add_be_ids.append(be.id)
         if args.IMAGE:
             for node in related_nodes:
                 node.set_image(args.IMAGE)
@@ -218,11 +256,47 @@ class UpCommand(Command):
                     "Not up cluster cause specific --no-start, related node 
num {}"
                     .format(related_node_num)))
         else:
+            if args.wait_timeout != 0:
+                if args.wait_timeout == -1:
+                    args.wait_timeout = 1000000000
+                expire_ts = time.time() + args.wait_timeout
+                while True:
+                    db_mgr = database.get_db_mgr(args.NAME, False)
+                    dead_frontends = []
+                    for id in add_fe_ids:
+                        fe_state = db_mgr.get_fe(id)
+                        if not fe_state or not fe_state.alive:
+                            dead_frontends.append(id)
+                    dead_backends = []
+                    for id in add_be_ids:
+                        be_state = db_mgr.get_be(id)
+                        if not be_state or not be_state.alive:
+                            dead_backends.append(id)
+                    if not dead_frontends and not dead_backends:
+                        break
+                    if time.time() >= expire_ts:
+                        err = ""
+                        if dead_frontends:
+                            err += "dead fe: " + str(dead_frontends) + ". "
+                        if dead_backends:
+                            err += "dead be: " + str(dead_backends) + ". "
+                        raise Exception(err)
+                    time.sleep(1)
             LOG.info(
                 utils.render_green(
                     "Up cluster {} succ, related node num {}".format(
                         args.NAME, related_node_num)))
 
+        db_mgr = database.get_db_mgr(args.NAME, False)
+        return {
+            "fe": {
+                "add_list": add_fe_ids,
+            },
+            "be": {
+                "add_list": add_be_ids,
+            },
+        }
+
 
 class DownCommand(Command):
 
@@ -234,6 +308,7 @@ class DownCommand(Command):
                                            "then apply to all containers.")
         parser.add_argument("NAME", help="Specify cluster name")
         self._add_parser_ids_args(parser)
+        self._add_parser_output_json(parser)
         parser.add_argument(
             "--clean",
             default=False,
@@ -249,7 +324,11 @@ class DownCommand(Command):
                     "it will send dropp to fe, otherwise send decommission to 
fe.")
 
     def run(self, args):
-        cluster = CLUSTER.Cluster.load(args.NAME)
+        cluster = None
+        try:
+            cluster = CLUSTER.Cluster.load(args.NAME)
+        except:
+            return "Cluster not exists or load failed"
         for_all, related_nodes, related_node_num = get_ids_related_nodes(
             cluster, args.fe_id, args.be_id, ignore_not_exists=True)
 
@@ -304,12 +383,15 @@ class DownCommand(Command):
                 "Down cluster {} succ, related node num {}".format(
                     args.NAME, related_node_num)))
 
+        return "down cluster succ"
+
 
 class ListNode(object):
 
     def __init__(self):
         self.node_type = ""
         self.id = 0
+        self.backend_id = ""
         self.cluster_name = ""
         self.ip = ""
         self.status = ""
@@ -323,11 +405,18 @@ class ListNode(object):
         self.last_heartbeat = ""
         self.err_msg = ""
 
-    def info(self):
-        return (self.cluster_name, "{}-{}".format(self.node_type, self.id),
-                self.ip, self.status, self.container_id, self.image,
-                self.created, self.alive, self.is_master, self.query_port,
-                self.tablet_num, self.last_heartbeat, self.err_msg)
+    def info(self, detail):
+        result = [
+            self.cluster_name, "{}-{}".format(self.node_type, self.id),
+            self.ip, self.status, self.container_id, self.image, self.created,
+            self.alive, self.is_master, self.query_port, self.backend_id,
+            self.tablet_num, self.last_heartbeat, self.err_msg
+        ]
+        if detail:
+            result += [
+                CLUSTER.FE_QUERY_PORT,
+            ]
+        return result
 
     def update_db_info(self, db_mgr):
         if self.node_type == CLUSTER.Node.TYPE_FE:
@@ -339,9 +428,11 @@ class ListNode(object):
                 self.last_heartbeat = fe.last_heartbeat
                 self.err_msg = fe.err_msg
         elif self.node_type == CLUSTER.Node.TYPE_BE:
+            self.backend_id = -1
             be = db_mgr.get_be(self.id)
             if be:
                 self.alive = str(be.alive).lower()
+                self.backend_id = be.backend_id
                 self.tablet_num = be.tablet_num
                 self.last_heartbeat = be.last_heartbeat
                 self.err_msg = be.err_msg
@@ -358,12 +449,29 @@ class ListCommand(Command):
             help=
             "Specify multiple clusters, if specific, show all their 
containers."
         )
+        self._add_parser_output_json(parser)
         parser.add_argument(
             "-a",
             "--all",
             default=False,
             action=self._get_parser_bool_action(True),
             help="Show all stopped and bad doris compose projects")
+        parser.add_argument("--detail",
+                            default=False,
+                            action=self._get_parser_bool_action(True),
+                            help="Print more detail fields.")
+
+    def _handle_data(self, header, datas):
+        if utils.is_enable_log():
+            table = prettytable.PrettyTable(
+                [utils.render_green(field) for field in header])
+            for row in datas:
+                table.add_row(row)
+            print(table)
+            return ""
+        else:
+            datas.insert(0, header)
+            return datas
 
     def run(self, args):
         COMPOSE_MISSING = "(missing)"
@@ -427,9 +535,8 @@ class ListCommand(Command):
 
         TYPE_COMPOSESERVICE = type(ComposeService("", "", ""))
         if not args.NAME:
-            headers = (utils.render_green(field)
-                       for field in ("CLUSTER", "STATUS", "CONFIG FILES"))
-            table = prettytable.PrettyTable(headers)
+            header = ("CLUSTER", "STATUS", "CONFIG FILES")
+            rows = []
             for name in sorted(clusters.keys()):
                 cluster_info = clusters[name]
                 service_statuses = {}
@@ -445,19 +552,22 @@ class ListCommand(Command):
                 if not args.all and service_statuses.get("running", 0) == 0:
                     continue
                 compose_file = CLUSTER.get_compose_file(name)
-                table.add_row(
+                rows.append(
                     (name, show_status, "{}{}".format(compose_file,
                                                       cluster_info["status"])))
-            print(table)
-            return
-
-        headers = (utils.render_green(field)
-                   for field in ("CLUSTER", "NAME", "IP", "STATUS",
-                                 "CONTAINER ID", "IMAGE", "CREATED", "alive",
-                                 "is_master", "query_port", "tablet_num",
-                                 "last_heartbeat", "err_msg"))
-        table = prettytable.PrettyTable(headers)
-
+            return self._handle_data(header, rows)
+
+        header = [
+            "CLUSTER", "NAME", "IP", "STATUS", "CONTAINER ID", "IMAGE",
+            "CREATED", "alive", "is_master", "query_port", "backend_id",
+            "tablet_num", "last_heartbeat", "err_msg"
+        ]
+        if args.detail:
+            header += [
+                "query_port",
+            ]
+
+        rows = []
         for cluster_name in sorted(clusters.keys()):
             fe_ids = {}
             be_ids = {}
@@ -526,9 +636,9 @@ class ListCommand(Command):
                 return key
 
             for node in sorted(nodes, key=get_key):
-                table.add_row(node.info())
+                rows.append(node.info(args.detail))
 
-        print(table)
+        return self._handle_data(header, rows)
 
 
 ALL_COMMANDS = [
diff --git a/docker/runtime/doris-compose/database.py 
b/docker/runtime/doris-compose/database.py
index 57fc90275a..69b2cb50f2 100644
--- a/docker/runtime/doris-compose/database.py
+++ b/docker/runtime/doris-compose/database.py
@@ -38,9 +38,10 @@ class FEState(object):
 
 class BEState(object):
 
-    def __init__(self, id, decommissioned, alive, tablet_num, last_heartbeat,
-                 err_msg):
+    def __init__(self, id, backend_id, decommissioned, alive, tablet_num,
+                 last_heartbeat, err_msg):
         self.id = id
+        self.backend_id = backend_id
         self.decommissioned = decommissioned
         self.alive = alive
         self.tablet_num = tablet_num
@@ -120,7 +121,7 @@ class DBManager(object):
         except Exception as e:
             if str(e).find("Backend does not exist") >= 0:
                 LOG.info("Decommission be {} with id {} from db succ " \
-                        "cause it does not exist in db.",format(be_endpoint, 
id))
+                        "cause it does not exist in db.".format(be_endpoint, 
id))
                 return
             raise e
 
@@ -142,12 +143,12 @@ class DBManager(object):
     def _load_fe_states(self, query_ports):
         fe_states = {}
         alive_master_fe_port = None
-        for record in self._exec_query("show frontends;"):
-            ip = record[1]
-            is_master = record[7] == "true"
-            alive = record[10] == "true"
-            last_heartbeat = record[12]
-            err_msg = record[14]
+        for record in self._exec_query('''
+            select Host, IsMaster, Alive, LastHeartbeat, ErrMsg
+            from frontends()'''):
+            ip, is_master, alive, last_heartbeat, err_msg = record
+            is_master = utils.is_true(is_master)
+            alive = utils.is_true(alive)
             id = CLUSTER.Node.get_id_from_ip(ip)
             query_port = query_ports.get(id, None)
             fe = FEState(id, query_port, is_master, alive, last_heartbeat,
@@ -162,16 +163,17 @@ class DBManager(object):
 
     def _load_be_states(self):
         be_states = {}
-        for record in self._exec_query("show backends;"):
-            ip = record[1]
-            last_heartbeat = record[7]
-            alive = record[8] == "true"
-            decommissioned = record[9] == "true"
-            tablet_num = int(record[10])
-            err_msg = record[18]
+        for record in self._exec_query('''
+            select BackendId, Host, LastHeartbeat, Alive, 
SystemDecommissioned, TabletNum, ErrMsg
+            from backends()'''):
+            backend_id, ip, last_heartbeat, alive, decommissioned, tablet_num, 
err_msg = record
+            backend_id = int(backend_id)
+            alive = utils.is_true(alive)
+            decommissioned = utils.is_true(decommissioned)
+            tablet_num = int(tablet_num)
             id = CLUSTER.Node.get_id_from_ip(ip)
-            be = BEState(id, decommissioned, alive, tablet_num, last_heartbeat,
-                         err_msg)
+            be = BEState(id, backend_id, decommissioned, alive, tablet_num,
+                         last_heartbeat, err_msg)
             be_states[id] = be
         self.be_states = be_states
 
diff --git a/docker/runtime/doris-compose/doris-compose.py 
b/docker/runtime/doris-compose/doris-compose.py
index 77ceb736e3..c29449b116 100644
--- a/docker/runtime/doris-compose/doris-compose.py
+++ b/docker/runtime/doris-compose/doris-compose.py
@@ -17,6 +17,8 @@
 
 import argparse
 import command
+import sys
+import traceback
 import utils
 
 
@@ -29,20 +31,35 @@ def parse_args():
     return ap.parse_args(), ap.format_help()
 
 
-def run(args, help):
-    timer = utils.Timer()
+def run(args, disable_log, help):
     for cmd in command.ALL_COMMANDS:
         if args.command == cmd.name:
-            return cmd.run(args)
-    timer.cancel()
+            timer = utils.Timer()
+            result = cmd.run(args)
+            if not disable_log:
+                timer.show()
+            return result
     print(help)
-    return -1
+    return ""
 
 
-def main():
+if __name__ == '__main__':
     args, help = parse_args()
-    run(args, help)
-
+    disable_log = getattr(args, "output_json", False)
+    if disable_log:
+        utils.set_enable_log(False)
 
-if __name__ == '__main__':
-    main()
+    code = None
+    try:
+        data = run(args, disable_log, help)
+        if disable_log:
+            print(utils.pretty_json({"code": 0, "data": data}))
+        code = 0
+    except:
+        err = traceback.format_exc()
+        if disable_log:
+            print(utils.pretty_json({"code": 1, "err": err}))
+        else:
+            print(err)
+        code = 1
+    sys.exit(code)
diff --git a/docker/runtime/doris-compose/resource/init_be.sh 
b/docker/runtime/doris-compose/resource/init_be.sh
index 9a59876382..d9f4abf125 100644
--- a/docker/runtime/doris-compose/resource/init_be.sh
+++ b/docker/runtime/doris-compose/resource/init_be.sh
@@ -28,6 +28,11 @@ add_backend() {
             sleep 1
             continue
         fi
+        lsof -i:$BE_HEARTBEAT_PORT
+        if [ $? -ne 0 ]; then
+            sleep 1
+            continue
+        fi
 
         output=`mysql -P $FE_QUERY_PORT -h $MASTER_FE_IP -u root --execute 
"ALTER SYSTEM ADD BACKEND '$MY_IP:$BE_HEARTBEAT_PORT';" 2>&1`
         res=$?
@@ -42,7 +47,7 @@ add_backend() {
 
 main() {
     if [ ! -f $REGISTER_FILE ]; then
-        add_backend
+        add_backend &
     fi
     bash $DORIS_HOME/bin/start_be.sh
 }
diff --git a/docker/runtime/doris-compose/resource/init_fe.sh 
b/docker/runtime/doris-compose/resource/init_fe.sh
index fbbd335f37..463fa8c667 100644
--- a/docker/runtime/doris-compose/resource/init_fe.sh
+++ b/docker/runtime/doris-compose/resource/init_fe.sh
@@ -39,20 +39,42 @@ add_frontend() {
 fe_daemon() {
     set +e
     while true; do
-        output=`mysql -P $FE_QUERY_PORT -h $MY_IP -u root --execute "SHOW 
FRONTENDS;" | grep -w $MY_IP | awk '{print $8}' 2>&1`
+        sleep 1
+        output=`mysql -P $FE_QUERY_PORT -h $MY_IP -u root --execute "SHOW 
FRONTENDS;"`
+        code=$?
+        health_log "$output"
+        if [ $code -ne 0 ]; then
+            continue
+        fi
+        header=`grep IsMaster <<< $output`
         if [ $? -ne 0 ]; then
-            health_log "$output"
-        else
-            echo $output | grep true
-            if [ $? -eq 0 ]; then
-                echo $MY_IP > $MASTER_FE_IP_FILE
-                if [ "$MASTER_FE_IP" != "$MY_IP" ]; then
-                    health_log "change to master, last master is $MASTER_FE_IP"
-                    MASTER_FE_IP=$MY_IP
-                fi
+            health_log "not found header"
+            continue
+        fi
+        host_index=-1
+        is_master_index=-1
+        i=1
+        for field in $header; do
+            [[ "$field" = "Host" ]] && host_index=$i
+            [[ "$field" = "IsMaster" ]] && is_master_index=$i
+            ((i=i+1))
+        done
+        if [ $host_index -eq -1 ]; then
+            health_log "header not found Host"
+            continue
+        fi
+        if [ $is_master_index -eq -1 ]; then
+            health_log "header not found IsMaster"
+            continue
+        fi
+        echo "$output" | awk -v is_master="$is_master_index" -v 
host="$host_index" '{print $is_master $host}' | grep $MY_IP | grep true 2>&1
+        if [ $? -eq 0 ]; then
+            echo $MY_IP > $MASTER_FE_IP_FILE
+            if [ "$MASTER_FE_IP" != "$MY_IP" ]; then
+                health_log "change to master, last master is $MASTER_FE_IP"
+                MASTER_FE_IP=$MY_IP
             fi
         fi
-        sleep 3
     done
 }
 
diff --git a/docker/runtime/doris-compose/utils.py 
b/docker/runtime/doris-compose/utils.py
index 7317715fe9..dfed11e8b0 100644
--- a/docker/runtime/doris-compose/utils.py
+++ b/docker/runtime/doris-compose/utils.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import docker
+import json
 import logging
 import os
 import subprocess
@@ -24,6 +25,10 @@ import yaml
 
 DORIS_PREFIX = "doris-"
 
+LOG = None
+
+ENABLE_LOG = True
+
 
 class Timer(object):
 
@@ -31,7 +36,7 @@ class Timer(object):
         self.start = time.time()
         self.canceled = False
 
-    def __del__(self):
+    def show(self):
         if not self.canceled:
             LOG.info("=== Total run time: {} s".format(
                 int(time.time() - self.start)))
@@ -40,7 +45,21 @@ class Timer(object):
         self.canceled = True
 
 
+def set_enable_log(enabled):
+    global ENABLE_LOG
+    ENABLE_LOG = enabled
+    get_logger().disabled = not enabled
+
+
+def is_enable_log():
+    return ENABLE_LOG
+
+
 def get_logger(name=None):
+    global LOG
+    if LOG != None:
+        return LOG
+
     logger = logging.getLogger(name)
     if not logger.hasHandlers():
         formatter = logging.Formatter(
@@ -52,10 +71,12 @@ def get_logger(name=None):
         logger.addHandler(ch)
         logger.setLevel(logging.INFO)
 
+    LOG = logger
+
     return logger
 
 
-LOG = get_logger()
+get_logger()
 
 
 def render_red(s):
@@ -159,7 +180,7 @@ def exec_shell_command(command, ignore_errors=False):
     out = p.communicate()[0].decode('utf-8')
     if not ignore_errors:
         assert p.returncode == 0, out
-    if out:
+    if ENABLE_LOG and out:
         print(out)
     return p.returncode, out
 
@@ -251,3 +272,11 @@ def read_compose_file(file):
 def write_compose_file(file, compose):
     with open(file, "w") as f:
         f.write(yaml.dump(compose))
+
+
+def pretty_json(json_data):
+    return json.dumps(json_data, indent=4, sort_keys=True)
+
+
+def is_true(val):
+    return str(val) == "true" or str(val) == "1"
diff --git a/regression-test/conf/regression-conf.groovy 
b/regression-test/conf/regression-conf.groovy
index 5b204b53d2..b5114f9dce 100644
--- a/regression-test/conf/regression-conf.groovy
+++ b/regression-test/conf/regression-conf.groovy
@@ -47,6 +47,13 @@ pluginPath = "${DORIS_HOME}/regression-test/plugins"
 realDataPath = "${DORIS_HOME}/regression-test/realdata"
 sslCertificatePath = "${DORIS_HOME}/regression-test/ssl_default_certificate"
 
+// docker image
+image = ""
+dockerEndDeleteFiles = false
+dorisComposePath = 
"${DORIS_HOME}/docker/runtime/doris-compose/doris-compose.py"
+// do run docker test because pipeline not support build image now
+excludeDockerTest = true
+
 // will test <group>/<suite>.groovy
 // empty group will test all group
 testGroups = ""
diff --git 
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/Config.groovy
 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/Config.groovy
index cff342889c..8fcba76f0f 100644
--- 
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/Config.groovy
+++ 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/Config.groovy
@@ -60,6 +60,10 @@ class Config {
     public boolean enableCacheData
     public String pluginPath
     public String sslCertificatePath
+    public String dorisComposePath
+    public String image
+    public Boolean dockerEndDeleteFiles
+    public Boolean excludeDockerTest
 
     public String testGroups
     public String excludeGroups
@@ -157,6 +161,8 @@ class Config {
         config.enableCacheData = 
Boolean.parseBoolean(cmd.getOptionValue(enableCacheDataOpt, "true"))
         config.pluginPath = 
FileUtils.getCanonicalPath(cmd.getOptionValue(pluginOpt, config.pluginPath))
         config.sslCertificatePath = 
FileUtils.getCanonicalPath(cmd.getOptionValue(sslCertificateOpt, 
config.sslCertificatePath))
+        config.dorisComposePath = 
FileUtils.getCanonicalPath(config.dorisComposePath)
+        config.image = cmd.getOptionValue(imageOpt, config.image)
         config.suiteWildcard = cmd.getOptionValue(suiteOpt, config.testSuites)
                 .split(",")
                 .collect({s -> s.trim()})
@@ -303,6 +309,10 @@ class Config {
             configToString(obj.sslCertificatePath)
         )
 
+        config.image = configToString(obj.image)
+        config.dockerEndDeleteFiles = configToBoolean(obj.dockerEndDeleteFiles)
+        config.excludeDockerTest = configToBoolean(obj.excludeDockerTest)
+
         def declareFileNames = config.getClass()
                 .getDeclaredFields()
                 .collect({f -> f.name})
@@ -323,7 +333,7 @@ class Config {
         }
 
         if (config.jdbcUrl == null) {
-            //jdbcUrl needs parameter here. Refer to function: buildUrl(String 
dbName)
+            //jdbcUrl needs parameter here. Refer to function: 
buildUrlWithDb(String jdbcUrl, String dbName)
             config.jdbcUrl = 
"jdbc:mysql://127.0.0.1:9030/?useLocalSessionState=true&allowLoadLocalInfile=true"
             log.info("Set jdbcUrl to '${config.jdbcUrl}' because not 
specify.".toString())
         }
@@ -418,6 +428,21 @@ class Config {
             log.info("Set sslCertificatePath to '${config.sslCertificatePath}' 
because not specify.".toString())
         }
 
+        if (config.dockerEndDeleteFiles == null) {
+            config.dockerEndDeleteFiles = false
+            log.info("Set dockerEndDeleteFiles to 
'${config.dockerEndDeleteFiles}' because not specify.".toString())
+        }
+
+        if (config.excludeDockerTest == null) {
+            config.excludeDockerTest = true
+            log.info("Set excludeDockerTest to '${config.excludeDockerTest}' 
because not specify.".toString())
+        }
+
+        if (config.dorisComposePath == null) {
+            config.dorisComposePath = 
"docker/runtime/doris-compose/doris-compose.py"
+            log.info("Set dorisComposePath to '${config.dorisComposePath}' 
because not specify.".toString())
+        }
+
         if (config.testGroups == null) {
             config.testGroups = "default"
             log.info("Set testGroups to '${config.testGroups}' because not 
specify.".toString())
@@ -502,7 +527,7 @@ class Config {
     }
 
     Connection getConnectionByDbName(String dbName) {
-        String dbUrl = buildUrl(dbName)
+        String dbUrl = buildUrlWithDb(jdbcUrl, dbName)
         tryCreateDbIfNotExist(dbName)
         log.info("connect to ${dbUrl}".toString())
         return DriverManager.getConnection(dbUrl, jdbcUser, jdbcPassword)
@@ -556,12 +581,12 @@ class Config {
         }
     }
 
-    private void buildUrlWithDefaultDb() {
-        this.jdbcUrl = buildUrl(defaultDb)
+    public void buildUrlWithDefaultDb() {
+        this.jdbcUrl = buildUrlWithDb(jdbcUrl, defaultDb)
         log.info("Reset jdbcUrl to ${jdbcUrl}".toString())
     }
 
-    private String buildUrl(String dbName) {
+    public static String buildUrlWithDb(String jdbcUrl, String dbName) {
         String urlWithDb = jdbcUrl
         String urlWithoutSchema = jdbcUrl.substring(jdbcUrl.indexOf("://") + 3)
         if (urlWithoutSchema.indexOf("/") >= 0) {
@@ -584,7 +609,7 @@ class Config {
         return urlWithDb
     }
 
-    private String addSslUrl(String url) {
+    private static String addSslUrl(String url) {
         if (url.contains("TLS")) {
             return url
         }
@@ -606,7 +631,7 @@ class Config {
         }
     }
 
-    private String addTimeoutUrl(String url) {
+    private static String addTimeoutUrl(String url) {
         if (url.contains("connectTimeout=") || url.contains("socketTimeout="))
         {
             return url
diff --git 
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/ConfigOptions.groovy
 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/ConfigOptions.groovy
index 9c5db531ca..7ad0720bd1 100644
--- 
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/ConfigOptions.groovy
+++ 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/ConfigOptions.groovy
@@ -48,6 +48,7 @@ class ConfigOptions {
     static Option enableCacheDataOpt
     static Option pluginOpt
     static Option sslCertificateOpt
+    static Option imageOpt
     static Option suiteOpt
     static Option excludeSuiteOpt
     static Option groupsOpt
@@ -172,6 +173,15 @@ class ConfigOptions {
                 .desc("the sslCertificate path")
                 .build() 
 
+        imageOpt = Option.builder("image")
+                .argName("image")
+                .required(false)
+                .hasArg(true)
+                .type(String.class)
+                .longOpt("image")
+                .desc("the docker image")
+                .build()
+
         suiteOpt = Option.builder("s")
                 .argName("suiteName")
                 .required(false)
@@ -381,6 +391,7 @@ class ConfigOptions {
                 .addOption(dataOpt)
                 .addOption(pluginOpt)
                 .addOption(sslCertificateOpt)
+                .addOption(imageOpt)
                 .addOption(confOpt)
                 .addOption(suiteOpt)
                 .addOption(excludeSuiteOpt)
diff --git 
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
index 167a294d00..23ae47aa38 100644
--- 
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
+++ 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
@@ -24,6 +24,7 @@ import com.google.common.util.concurrent.MoreExecutors
 import com.google.gson.Gson
 import groovy.json.JsonSlurper
 import com.google.common.collect.ImmutableList
+import org.apache.doris.regression.Config
 import org.apache.doris.regression.action.BenchmarkAction
 import org.apache.doris.regression.util.DataUtils
 import org.apache.doris.regression.util.OutputUtils
@@ -49,6 +50,7 @@ import java.util.stream.Collectors
 import java.util.stream.LongStream
 import static org.apache.doris.regression.util.DataUtils.sortByToString
 
+import java.sql.DriverManager
 import java.sql.PreparedStatement
 import java.sql.ResultSetMetaData
 import org.junit.Assert
@@ -66,10 +68,13 @@ class Suite implements GroovyInterceptable {
     final List<Throwable> lazyCheckExceptions = new Vector<>()
     final List<Future> lazyCheckFutures = new Vector<>()
 
+    SuiteCluster cluster
+
     Suite(String name, String group, SuiteContext context) {
         this.name = name
         this.group = group
         this.context = context
+        this.cluster = null
     }
 
     String getConf(String key, String defaultValue = null) {
@@ -192,6 +197,35 @@ class Suite implements GroovyInterceptable {
         return context.connect(user, password, url, actionSupplier)
     }
 
+    public void docker(ClusterOptions options = new ClusterOptions(), Closure 
actionSupplier) throws Exception {
+        if (context.config.excludeDockerTest) {
+            return
+        }
+
+        cluster = new SuiteCluster(name, context.config)
+        try {
+            cluster.destroy(true)
+            cluster.init(options)
+
+            def user = "root"
+            def password = ""
+            def masterFe = cluster.getMasterFe()
+            def url = String.format(
+                    
"jdbc:mysql://%s:%s/?useLocalSessionState=false&allowLoadLocalInfile=false",
+                    masterFe.host, masterFe.queryPort)
+            def conn = DriverManager.getConnection(url, user, password)
+            def sql = "CREATE DATABASE IF NOT EXISTS " + context.dbName
+            logger.info("try create database if not exists {}", context.dbName)
+            JdbcUtils.executeToList(conn, sql)
+            url = Config.buildUrlWithDb(url, context.dbName)
+
+            logger.info("connect to docker cluster: suite={}, url={}", name, 
url)
+            connect(user, password, url, actionSupplier)
+        } finally {
+            cluster.destroy(context.config.dockerEndDeleteFiles)
+        }
+    }
+
     String get_ccr_body(String table) {
         Gson gson = new Gson()
 
diff --git 
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteCluster.groovy
 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteCluster.groovy
new file mode 100644
index 0000000000..85dbea17b5
--- /dev/null
+++ 
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteCluster.groovy
@@ -0,0 +1,363 @@
+// 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.
+
+package org.apache.doris.regression.suite
+
+import org.apache.doris.regression.Config
+
+import com.google.common.collect.Maps
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+import groovy.json.JsonSlurper
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import java.util.stream.Collectors
+
+class ClusterOptions {
+    int feNum = 1
+    int beNum = 3
+    int beDiskNum = 1
+    List<String> feConfigs
+    List<String> beConfigs
+}
+
+class ListHeader {
+    Map<String, Integer> fields;
+
+    ListHeader(List<Object> fieldList) {
+        this.fields = Maps.newHashMap()
+        for (int i = 0; i < fieldList.size(); i++) {
+            fields.put((String) fieldList.get(i), i)
+        }
+    }
+
+    int indexOf(String field) {
+        def index = fields.get(field)
+        assert index != null : "Not found field: " + field
+        return index
+    }
+}
+
+class Frontend {
+    int index
+    String host
+    int queryPort
+    boolean alive
+    boolean isMaster
+
+    static Frontend fromCompose(ListHeader header, int index, List<Object> 
fields) {
+        Frontend fe = new Frontend()
+        fe.index = index
+        fe.host = (String) fields.get(header.indexOf("IP"))
+        fe.queryPort = (Integer) fields.get(header.indexOf("query_port"))
+        fe.alive = fields.get(header.indexOf("alive")) == "true"
+        fe.isMaster = fields.get(header.indexOf("is_master")) == "true"
+        return fe
+    }
+}
+
+class Backend {
+    int index
+    long backendId
+    String host
+    boolean alive
+    int tabletNum
+
+    static Backend fromCompose(ListHeader header, int index, List<Object> 
fields) {
+        Backend be = new Backend()
+        be.index = index
+        be.backendId = (long) fields.get(header.indexOf("backend_id"))
+        be.host = (String) fields.get(header.indexOf("IP"))
+        be.alive = fields.get(header.indexOf("alive")) == "true"
+        be.tabletNum = (int) fields.get(header.indexOf("tablet_num"))
+        return be
+    }
+}
+
+@Slf4j
+@CompileStatic
+class SuiteCluster {
+    static final Logger logger = LoggerFactory.getLogger(this.class)
+
+    final String name
+    final Config config
+    private boolean inited
+
+    SuiteCluster(String name, Config config) {
+        this.name = name
+        this.config = config
+        this.inited = false
+    }
+
+    void init(ClusterOptions options) {
+        if (inited) {
+            return;
+        }
+
+        assert name != null && name != ""
+        assert options.feNum > 0 || options.beNum > 0
+        assert config.image != null && config.image != ""
+
+        def sb = new StringBuilder()
+        sb.append("up " + name + " ")
+        sb.append(config.image + " ")
+        if (options.feNum > 0) {
+            sb.append("--add-fe-num " + options.feNum + " ")
+        }
+        if (options.beNum > 0) {
+            sb.append("--add-be-num " + options.beNum + " ")
+        }
+        // TODO: need escape white space in config
+        if (options.feConfigs != null && options.feConfigs.size() > 0) {
+            sb.append("--fe-config ")
+            options.feConfigs.forEach(item -> sb.append(" " + item + " "))
+        }
+        if (options.beConfigs != null && options.beConfigs.size() > 0) {
+            sb.append("--be-config ")
+            options.beConfigs.forEach(item -> sb.append(" " + item + " "))
+        }
+        sb.append("--be-disk-num " + options.beDiskNum + " ")
+        sb.append("--wait-timeout 180")
+
+        runCmd(sb.toString(), -1)
+
+        // wait be report disk
+        Thread.sleep(5000)
+
+        inited = true;
+    }
+
+    Frontend getMasterFe() {
+        return getFrontends().stream().filter(fe -> 
fe.isMaster).findFirst().orElse(null)
+    }
+
+    Frontend getFeByIndex(int index) {
+        return getFrontends().stream().filter(fe -> fe.index == 
index).findFirst().orElse(null)
+    }
+
+    Backend getBeByBackendId(long backendId) {
+        return getBackends().stream().filter(be -> be.backendId == 
backendId).findFirst().orElse(null)
+    }
+
+    Backend getBeByIndex(int index) {
+        return getBackends().stream().filter(be -> be.index == 
index).findFirst().orElse(null)
+    }
+
+    List<Frontend> getAllFrontends(boolean needAlive = false) {
+        return getFrontends().stream().filter(fe -> fe.alive || 
!needAlive).collect(Collectors.toList());
+    }
+
+    List<Backend> getAllBackends(boolean needAlive = false) {
+        return getBackends().stream().filter(be -> be.alive || 
!needAlive).collect(Collectors.toList());
+    }
+
+    private List<Frontend> getFrontends() {
+        List<Frontend> frontends = []
+        List<Backend> backends = []
+        getAllNodes(frontends, backends)
+        return frontends
+    }
+
+    private List<Backend> getBackends() {
+        List<Frontend> frontends = []
+        List<Backend> backends = []
+        getAllNodes(frontends, backends)
+        return backends
+    }
+
+    private void getAllNodes(List<Frontend> frontends, List<Backend> backends) 
{
+        def cmd = "ls " + name + " --detail"
+        def data = runCmd(cmd)
+        assert data instanceof List
+        def rows = (List<List<Object>>) data
+        def header = new ListHeader(rows.get(0))
+        for (int i = 1; i < rows.size(); i++) {
+            def row = (List<Object>) rows.get(i)
+            def name = (String) row.get(header.indexOf("NAME"))
+            if (name.startsWith("be-")) {
+                int index = name.substring("be-".length()) as int
+                backends.add(Backend.fromCompose(header, index, row))
+            } else if (name.startsWith("fe-")) {
+                int index = name.substring("fe-".length()) as int
+                frontends.add(Frontend.fromCompose(header, index, row))
+            } else {
+                assert false : "Unknown node type with name: " + name
+            }
+        }
+    }
+
+    List<Integer> addFe(int num) throws Exception {
+        def result = add(num, 0)
+        return result.first
+    }
+
+    List<Integer> addBe(int num) throws Exception {
+        def result = add(0, num)
+        return result.second
+    }
+
+    Tuple2<List<Integer>, List<Integer>> add(int feNum, int beNum) throws 
Exception {
+        assert feNum > 0 || beNum > 0
+
+        def sb = new StringBuilder()
+        sb.append("up " + name + " ")
+        if (feNum > 0) {
+            sb.append("--add-fe-num " + feNum + " ")
+        }
+        if (beNum > 0) {
+            sb.append("--add-be-num " + beNum + " ")
+        }
+        sb.append("--wait-timeout 60")
+
+        def data = (Map<String, Map<String, Object>>) runCmd(sb.toString(), -1)
+        def newFrontends = (List<Integer>) data.get("fe").get("add_list")
+        def newBackends = (List<Integer>) data.get("be").get("add_list")
+
+        // wait be report disk
+        Thread.sleep(5000)
+
+        return new Tuple2(newFrontends, newBackends)
+    }
+
+    void destroy(boolean clean) throws Exception {
+        def cmd = "down " + name
+        if (clean) {
+            cmd += " --clean"
+        }
+        runCmd(cmd)
+        inited = false
+    }
+
+    // if not specific fe indices, then start all frontends
+    void startFrontends(int... indices) {
+        runFrontendsCmd("start", indices)
+    }
+
+    // if not specific be indices, then start all backends
+    void startBackends(int... indices) {
+        runBackendsCmd("start", indices)
+    }
+
+    // if not specific fe indices, then stop all frontends
+    void stopFrontends(int... indices) {
+        runFrontendsCmd("stop", indices)
+    }
+
+    // if not specific be indices, then stop all backends
+    void stopBackends(int... indices) {
+        runBackendsCmd("stop", indices)
+    }
+
+    // if not specific fe indices, then restart all frontends
+    void restartFrontends(int... indices) {
+        runFrontendsCmd("restart", indices)
+    }
+
+    // if not specific be indices, then restart all backends
+    void restartBackends(int... indices) {
+        runBackendsCmd("restart", indices)
+    }
+
+    // if not specific fe indices, then drop all frontends
+    void dropFrontends(int... indices) {
+        runFrontendsCmd("down", indices)
+    }
+
+    // if not specific be indices, then decommission all backends
+    void decommissionBackends(int... indices) {
+        runBackendsCmdWithTimeout(300, "down", indices)
+    }
+
+    // if not specific be indices, then drop force all backends
+    void dropForceBackends(int... indices) {
+        runBackendsCmd("down --drop-force", indices)
+    }
+
+    void checkFeIsAlive(int index, boolean isAlive) {
+        def fe = getFeByIndex(index)
+        assert fe != null : "frontend with index " + index + " not exists!"
+        assert fe.alive == isAlive : (fe.alive ? "frontend with index " + 
index + " still alive"
+                : "frontend with index " + index + " dead")
+    }
+
+    void checkBeIsAlive(int index, boolean isAlive) {
+        def be = getBeByIndex(index)
+        assert be != null : "backend with index " + index + " not exists!"
+        assert be.alive == isAlive : (be.alive ? "backend with index " + index 
+ " still alive"
+                : "backend with index " + index + " dead")
+    }
+
+    void checkFeIsExists(int index, boolean isExists) {
+        def fe = getFeByIndex(index)
+        if (isExists) {
+            assert fe != null : "frontend with index " + index + " not exists!"
+        } else {
+            assert fe == null : "frontend with index " + index + " exists!"
+        }
+    }
+
+    void checkBeIsExists(int index, boolean isExists) {
+        def be = getBeByIndex(index)
+        if (isExists) {
+            assert be != null : "backend with index " + index + " not exists!"
+        } else {
+            assert be == null : "backend with index " + index + " exists!"
+        }
+    }
+
+    private void runFrontendsCmd(String op, int... indices) {
+        def cmd = op + " " + name + " --fe-id " + indices.join(" ")
+        runCmd(cmd)
+    }
+
+    private void runBackendsCmd(String op, int... indices) {
+        def cmd = op + " " + name + " --be-id " + indices.join(" ")
+        runCmd(cmd)
+    }
+
+    private void runBackendsCmdWithTimeout(int timeoutSecond, String op, 
int... indices) {
+        def cmd = op + " " + name + " --be-id " + indices.join(" ")
+        runCmd(cmd, timeoutSecond)
+    }
+
+    private Object runCmd(String cmd, int timeoutSecond = 60) throws Exception 
{
+        def fullCmd = String.format("python %s %s --output-json", 
config.dorisComposePath, cmd)
+        logger.info("Run doris compose cmd: {}", fullCmd)
+        def proc = fullCmd.execute()
+        def outBuf = new StringBuilder()
+        def errBuf = new StringBuilder()
+        proc.consumeProcessOutput(outBuf, errBuf)
+        if (timeoutSecond > 0) {
+            proc.waitForOrKill(timeoutSecond * 1000)
+        } else {
+            proc.waitFor()
+        }
+        def out = outBuf.toString()
+        def err = errBuf.toString()
+        if (proc.exitValue()) {
+            throw new Exception(String.format("Exit value: %s != 0, stdout: 
%s, stderr: %s",
+                                              proc.exitValue(), out, err))
+        }
+        def parser = new JsonSlurper()
+        def object = (Map<String, Object>) parser.parseText(out)
+        if (object.get("code") != 0) {
+            throw new Exception(String.format("Code: %s != 0, err: %s", 
object.get("code"), object.get("err")))
+        }
+        return object.get("data")
+    }
+}
diff --git a/regression-test/suites/chaos/test_docker_example.groovy 
b/regression-test/suites/chaos/test_docker_example.groovy
new file mode 100644
index 0000000000..5cb7238607
--- /dev/null
+++ b/regression-test/suites/chaos/test_docker_example.groovy
@@ -0,0 +1,53 @@
+// 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 org.apache.doris.regression.suite.ClusterOptions
+
+suite("test_docker_example") {
+    docker {
+        sql """create table tb1 (k int) DISTRIBUTED BY HASH(k) BUCKETS 10"""
+        sql """insert into tb1 values (1),(2),(3)"""
+
+        cluster.checkBeIsAlive(2, true)
+
+        // stop backend 2, 3
+        cluster.stopBackends(2, 3)
+        // wait be lost heartbeat
+        Thread.sleep(6000)
+
+        cluster.checkBeIsAlive(2, false)
+
+        test {
+            sql """insert into tb1 values (4),(5),(6)"""
+
+            // REPLICA_FEW_ERR
+            exception "errCode = 3,"
+        }
+    }
+
+    // run another docker
+    def options = new ClusterOptions()
+    // add fe config items
+    options.feConfigs = ["example_conf_k1=v1", "example_conf_k2=v2"]
+    // contains 5 backends
+    options.beNum = 5
+    // each backend has 3 disks
+    options.beDiskNum = 3
+    docker (options) {
+        sql """create table tb1 (k int) DISTRIBUTED BY HASH(k) BUCKETS 10 
properties ("replication_num"="5")"""
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to