From 7d8302fbd25637b9cb091bc17f1a82100e1990a9 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Wed, 26 Feb 2025 23:11:00 +0300 Subject: [PATCH 01/25] Test dockerfile for ubuntu 24.04 is added --- Dockerfile--ubuntu-24_04 | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Dockerfile--ubuntu-24_04 diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04 new file mode 100644 index 00000000..2ff753a4 --- /dev/null +++ b/Dockerfile--ubuntu-24_04 @@ -0,0 +1,81 @@ +FROM ubuntu:24.04 + +RUN apt update +#RUN apt -y install software-properties-common +#RUN add-apt-repository main +#RUN add-apt-repository universe + +#RUN apt-get update && apt-get install -y apt-transport-https +RUN apt install -y sudo curl ca-certificates postgresql-common +#RUN apt install -y lsb-core + +RUN bash /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y + +RUN install -d /usr/share/postgresql-common/pgdg +RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc + +# It does not work +#RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + +RUN apt update +RUN apt install -y postgresql-17 + +RUN apt install -y curl python3 python3-dev python3-virtualenv musl-dev +RUN apt install -y mc + +# It is required for psycopg2 +RUN apt install -y libpq-dev +RUN apt install -y openssh-server + +RUN mkdir -p /pg +COPY run_tests.sh /run.sh +RUN chmod 755 /run.sh + +# It adds the user 'postgres' in the group 'sudo' +RUN adduser postgres sudo + +ADD . /pg/testgres +WORKDIR /pg/testgres +RUN chown -R postgres /pg + +EXPOSE 22 + +RUN ssh-keygen -A + +# It enables execution of "sudo service ssh start" without password +RUN sh -c "echo postgres ALL=NOPASSWD:/usr/sbin/service ssh start" >> /etc/sudoers +# It requires for test those work through SSH +RUN mkdir -p /home/postgres +RUN chown postgres:postgres /home/postgres + +USER postgres + +ENV LANG=C.UTF-8 + +#ENTRYPOINT PYTHON_VERSION=3.12 /run.sh +ENTRYPOINT sh -c " \ +#set -eux; \ +echo HELLO FROM ENTRYPOINT; \ +echo HOME DIR IS [`realpath ~/`]; \ +echo POINT 1; \ +chmod go-w /var/lib/postgresql; \ +echo POINT 1.5; \ +mkdir -p ~/.ssh; \ +echo POINT 2; \ +service ssh enable; \ +echo POINT 3; \ +sudo service ssh start; \ +echo POINT 4; \ +ssh-keyscan -H localhost >> ~/.ssh/known_hosts; \ +echo POINT 5; \ +ssh-keyscan -H 127.0.0.1 >> ~/.ssh/known_hosts; \ +echo POINT 6; \ +ssh-keygen -t rsa -f ~/.ssh/id_rsa -q -N ''; \ +echo ----; \ +cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys; \ +echo ----; \ +chmod 600 ~/.ssh/authorized_keys; \ +echo ----; \ +ls -la ~/.ssh/; \ +echo ----; \ +bash;" From 2df2fd4e5035a15ed7a1582e4c4904762cbd95c4 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Wed, 26 Feb 2025 23:27:47 +0300 Subject: [PATCH 02/25] Cleanup (Dockerfile--ubuntu-24_04) --- Dockerfile--ubuntu-24_04 | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04 index 2ff753a4..5bb80806 100644 --- a/Dockerfile--ubuntu-24_04 +++ b/Dockerfile--ubuntu-24_04 @@ -1,13 +1,7 @@ FROM ubuntu:24.04 RUN apt update -#RUN apt -y install software-properties-common -#RUN add-apt-repository main -#RUN add-apt-repository universe - -#RUN apt-get update && apt-get install -y apt-transport-https RUN apt install -y sudo curl ca-certificates postgresql-common -#RUN apt install -y lsb-core RUN bash /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y From e8bf7d6e5e317700bd3a7d18281ecda7e2eb128d Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 13:00:01 +0300 Subject: [PATCH 03/25] Cleanup. Including of 'postgres' in 'sudo' group is not required. --- Dockerfile--ubuntu-24_04 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04 index 5bb80806..435bfa27 100644 --- a/Dockerfile--ubuntu-24_04 +++ b/Dockerfile--ubuntu-24_04 @@ -25,8 +25,9 @@ RUN mkdir -p /pg COPY run_tests.sh /run.sh RUN chmod 755 /run.sh -# It adds the user 'postgres' in the group 'sudo' -RUN adduser postgres sudo +# [2025-02-26] It adds the user 'postgres' in the group 'sudo' +# [2025-02-27] It is not required. +# RUN adduser postgres sudo ADD . /pg/testgres WORKDIR /pg/testgres From 103bcd2e6734a14ba8b8b43fb4899821f5b2e18c Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 13:17:59 +0300 Subject: [PATCH 04/25] [run_tests.sh] A right way for obtaining of BINDIR and PG_CONFIG is used A problem was detected in container with Ubuntu 24.04 tests works with "/usr/bin/pg_config" but real pg_config is "/usr/lib/postgresql/17/bin/pg_config" To resovle this problem we will call "pg_config --bindir" and use it result for BINDIR and PG_CONFIG. --- run_tests.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_tests.sh b/run_tests.sh index e9d58b54..5cbbac60 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -43,13 +43,13 @@ time coverage run -a -m pytest -l -v -n 4 -k "TestgresTests" # run tests (PG_BIN) time \ - PG_BIN=$(dirname $(which pg_config)) \ + PG_BIN=$(pg_config --bindir) \ coverage run -a -m pytest -l -v -n 4 -k "TestgresTests" # run tests (PG_CONFIG) time \ - PG_CONFIG=$(which pg_config) \ + PG_CONFIG=$(pg_config --bindir)/pg_config \ coverage run -a -m pytest -l -v -n 4 -k "TestgresTests" From 579d20efc4737524db80908425c22daced1151f9 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 13:44:59 +0300 Subject: [PATCH 05/25] Dockerfile--ubuntu-24_04 is updated Let's use /pg/testgres/run_tests.sh directly. --- Dockerfile--ubuntu-24_04 | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04 index 435bfa27..aa6495cd 100644 --- a/Dockerfile--ubuntu-24_04 +++ b/Dockerfile--ubuntu-24_04 @@ -21,10 +21,6 @@ RUN apt install -y mc RUN apt install -y libpq-dev RUN apt install -y openssh-server -RUN mkdir -p /pg -COPY run_tests.sh /run.sh -RUN chmod 755 /run.sh - # [2025-02-26] It adds the user 'postgres' in the group 'sudo' # [2025-02-27] It is not required. # RUN adduser postgres sudo @@ -73,4 +69,4 @@ chmod 600 ~/.ssh/authorized_keys; \ echo ----; \ ls -la ~/.ssh/; \ echo ----; \ -bash;" +bash /pg/testgres/run_tests.sh;" From e87c4029911b3b9b037c5961d2d2d8af19b2397d Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 13:47:10 +0300 Subject: [PATCH 06/25] Dockerfile--ubuntu-24_04 is updated (cleanup) curl is installing twice. --- Dockerfile--ubuntu-24_04 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04 index aa6495cd..5b31f005 100644 --- a/Dockerfile--ubuntu-24_04 +++ b/Dockerfile--ubuntu-24_04 @@ -14,7 +14,7 @@ RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail http RUN apt update RUN apt install -y postgresql-17 -RUN apt install -y curl python3 python3-dev python3-virtualenv musl-dev +RUN apt install -y python3 python3-dev python3-virtualenv musl-dev RUN apt install -y mc # It is required for psycopg2 From 96227f55567a7c65ab874272ac4ed18718bd0af9 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 17:13:17 +0300 Subject: [PATCH 07/25] Dockerfile--ubuntu-24_04 is updated (cleanup) [del] musl-dev [del] mc --- Dockerfile--ubuntu-24_04 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04 index 5b31f005..61109706 100644 --- a/Dockerfile--ubuntu-24_04 +++ b/Dockerfile--ubuntu-24_04 @@ -14,8 +14,8 @@ RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail http RUN apt update RUN apt install -y postgresql-17 -RUN apt install -y python3 python3-dev python3-virtualenv musl-dev -RUN apt install -y mc +RUN apt install -y python3 python3-dev python3-virtualenv +# RUN apt install -y mc # It is required for psycopg2 RUN apt install -y libpq-dev From b7bf71348d7d71ab73c3da3b2667be6a95209f8e Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 17:13:37 +0300 Subject: [PATCH 08/25] Dockerfile--ubuntu-24_04 is formatted --- Dockerfile--ubuntu-24_04 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04 index 61109706..03c05528 100644 --- a/Dockerfile--ubuntu-24_04 +++ b/Dockerfile--ubuntu-24_04 @@ -9,7 +9,7 @@ RUN install -d /usr/share/postgresql-common/pgdg RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc # It does not work -#RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' +# RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' RUN apt update RUN apt install -y postgresql-17 From bcb77713fb0554a68258b213cf3c91bf775a5401 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 17:38:36 +0300 Subject: [PATCH 09/25] CI-test on Ubuntu 24.04 is added. --- .travis.yml | 28 ++++++++----------- Dockerfile.tmpl => Dockerfile--std.tmpl | 0 ...ntu-24_04 => Dockerfile--ubuntu-24_04.tmpl | 4 +-- mk_dockerfile.sh | 2 +- 4 files changed, 14 insertions(+), 20 deletions(-) rename Dockerfile.tmpl => Dockerfile--std.tmpl (100%) rename Dockerfile--ubuntu-24_04 => Dockerfile--ubuntu-24_04.tmpl (94%) diff --git a/.travis.yml b/.travis.yml index 6f63a67b..4110835a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,23 +20,17 @@ notifications: on_failure: always env: - - PYTHON_VERSION=3 PG_VERSION=17 - - PYTHON_VERSION=3 PG_VERSION=16 - - PYTHON_VERSION=3 PG_VERSION=15 - - PYTHON_VERSION=3 PG_VERSION=14 - - PYTHON_VERSION=3 PG_VERSION=13 - - PYTHON_VERSION=3 PG_VERSION=12 - - PYTHON_VERSION=3 PG_VERSION=11 - - PYTHON_VERSION=3 PG_VERSION=10 -# - PYTHON_VERSION=3 PG_VERSION=9.6 -# - PYTHON_VERSION=3 PG_VERSION=9.5 -# - PYTHON_VERSION=3 PG_VERSION=9.4 -# - PYTHON_VERSION=2 PG_VERSION=10 -# - PYTHON_VERSION=2 PG_VERSION=9.6 -# - PYTHON_VERSION=2 PG_VERSION=9.5 -# - PYTHON_VERSION=2 PG_VERSION=9.4 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=17 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=16 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=15 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=14 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=13 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=12 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=11 + - TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=10 + - TEST_PLATFORM=ubuntu-24_04 PYTHON_VERSION=3 PG_VERSION=17 matrix: allow_failures: - - env: PYTHON_VERSION=3 PG_VERSION=11 - - env: PYTHON_VERSION=3 PG_VERSION=10 + - env: TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=11 + - env: TEST_PLATFORM=std PYTHON_VERSION=3 PG_VERSION=10 diff --git a/Dockerfile.tmpl b/Dockerfile--std.tmpl similarity index 100% rename from Dockerfile.tmpl rename to Dockerfile--std.tmpl diff --git a/Dockerfile--ubuntu-24_04 b/Dockerfile--ubuntu-24_04.tmpl similarity index 94% rename from Dockerfile--ubuntu-24_04 rename to Dockerfile--ubuntu-24_04.tmpl index 03c05528..b3e8482d 100644 --- a/Dockerfile--ubuntu-24_04 +++ b/Dockerfile--ubuntu-24_04.tmpl @@ -12,7 +12,7 @@ RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail http # RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' RUN apt update -RUN apt install -y postgresql-17 +RUN apt install -y postgresql-${PG_VERSION} RUN apt install -y python3 python3-dev python3-virtualenv # RUN apt install -y mc @@ -69,4 +69,4 @@ chmod 600 ~/.ssh/authorized_keys; \ echo ----; \ ls -la ~/.ssh/; \ echo ----; \ -bash /pg/testgres/run_tests.sh;" +PYTHON_VERSION=${PYTHON_VERSION} bash /pg/testgres/run_tests.sh;" diff --git a/mk_dockerfile.sh b/mk_dockerfile.sh index d2aa3a8a..8f7876a3 100755 --- a/mk_dockerfile.sh +++ b/mk_dockerfile.sh @@ -1,2 +1,2 @@ set -eu -sed -e 's/${PYTHON_VERSION}/'${PYTHON_VERSION}/g -e 's/${PG_VERSION}/'${PG_VERSION}/g Dockerfile.tmpl > Dockerfile +sed -e 's/${PYTHON_VERSION}/'${PYTHON_VERSION}/g -e 's/${PG_VERSION}/'${PG_VERSION}/g Dockerfile--${TEST_PLATFORM}.tmpl > Dockerfile From 7d871a28a97e204fb50cb9900a2cfb18f1b4da39 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 18:39:02 +0300 Subject: [PATCH 10/25] Dockerfile--std.tmpl is updated (refactoring) /pg/testgres/run_tests.sh is used directly. --- Dockerfile--std.tmpl | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Dockerfile--std.tmpl b/Dockerfile--std.tmpl index dc5878b6..d844c9a3 100644 --- a/Dockerfile--std.tmpl +++ b/Dockerfile--std.tmpl @@ -11,13 +11,9 @@ RUN if [ "${PYTHON_VERSION}" = "3" ] ; then \ fi ENV LANG=C.UTF-8 -RUN mkdir -p /pg -COPY run_tests.sh /run.sh -RUN chmod 755 /run.sh - ADD . /pg/testgres WORKDIR /pg/testgres RUN chown -R postgres:postgres /pg USER postgres -ENTRYPOINT PYTHON_VERSION=${PYTHON_VERSION} /run.sh +ENTRYPOINT PYTHON_VERSION=${PYTHON_VERSION} bash run_tests.sh From 381d64d5c321e59267df32d6f43251979b1a2965 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 18:41:40 +0300 Subject: [PATCH 11/25] Dockerfile--ubuntu-24_04.tmpl is updated --- Dockerfile--ubuntu-24_04.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile--ubuntu-24_04.tmpl b/Dockerfile--ubuntu-24_04.tmpl index b3e8482d..38a43570 100644 --- a/Dockerfile--ubuntu-24_04.tmpl +++ b/Dockerfile--ubuntu-24_04.tmpl @@ -69,4 +69,4 @@ chmod 600 ~/.ssh/authorized_keys; \ echo ----; \ ls -la ~/.ssh/; \ echo ----; \ -PYTHON_VERSION=${PYTHON_VERSION} bash /pg/testgres/run_tests.sh;" +PYTHON_VERSION=${PYTHON_VERSION} bash run_tests.sh;" From b5c4d7bd651d4f9ad1b1f2d3ab20b63ae67275b4 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 23:12:20 +0300 Subject: [PATCH 12/25] PostgresNode::pid is improved - We do multiple attempts to read pid file. - We process a case when we see that node is stopped between test and read. - We process a case when pid-file is empty. --- testgres/node.py | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/testgres/node.py b/testgres/node.py index 56899b90..890ffcc6 100644 --- a/testgres/node.py +++ b/testgres/node.py @@ -132,6 +132,9 @@ class PostgresNode(object): # a max number of node start attempts _C_MAX_START_ATEMPTS = 5 + # a max number of read pid file attempts + _C_MAX_GET_PID_ATEMPTS = 5 + def __init__(self, name=None, base_dir=None, port=None, conn_params: ConnectionParams = ConnectionParams(), bin_dir=None, prefix=None): """ @@ -208,14 +211,39 @@ def pid(self): Return postmaster's PID if node is running, else 0. """ - if self.status(): - pid_file = os.path.join(self.data_dir, PG_PID_FILE) - lines = self.os_ops.readlines(pid_file) - pid = int(lines[0]) if lines else None - return pid + nAttempt = 0 + pid_file = os.path.join(self.data_dir, PG_PID_FILE) + pid_s: str = None + while True: + if nAttempt == __class__._C_MAX_GET_PID_ATEMPTS: + errMsg = "Can't read postmaster pid file [{0}].".format(pid_file) + raise Exception(errMsg) - # for clarity - return 0 + nAttempt += 1 + + s1 = self.status() + if s1 != NodeStatus.Running: + return 0 + + try: + lines = self.os_ops.readlines(pid_file) + except Exception: + s2 = self.status() + if s2 == NodeStatus.Running: + raise + return 0 + + assert lines is not None # [2025-02-27] OK? + assert type(lines) == list # noqa: E721 + if len(lines) == 0: + continue + + pid_s = lines[0] + if len(pid_s) == 0: + continue + + pid = int(pid_s) + return pid @property def auxiliary_pids(self): From fce595a854cebe307d8290cc436298a7398c797d Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Thu, 27 Feb 2025 23:51:23 +0300 Subject: [PATCH 13/25] PostgresNode::pid is updated Assert is added. --- testgres/node.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testgres/node.py b/testgres/node.py index 890ffcc6..eed54408 100644 --- a/testgres/node.py +++ b/testgres/node.py @@ -239,6 +239,7 @@ def pid(self): continue pid_s = lines[0] + assert type(pid_s) == str # noqa: E721 if len(pid_s) == 0: continue From 0863cf582cc3a57fb1344017ffea86a7355affcb Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Fri, 28 Feb 2025 12:29:47 +0300 Subject: [PATCH 14/25] execute_utility2 is updated (ignore_errors) - New parameters "ignore_errors" is added. Default value is False. - Asserts are added. --- testgres/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/testgres/utils.py b/testgres/utils.py index 9645fc3b..76d42b02 100644 --- a/testgres/utils.py +++ b/testgres/utils.py @@ -73,11 +73,13 @@ def execute_utility(args, logfile=None, verbose=False): return execute_utility2(tconf.os_ops, args, logfile, verbose) -def execute_utility2(os_ops: OsOperations, args, logfile=None, verbose=False): +def execute_utility2(os_ops: OsOperations, args, logfile=None, verbose=False, ignore_errors=False): assert os_ops is not None assert isinstance(os_ops, OsOperations) + assert type(verbose) == bool # noqa: E721 + assert type(ignore_errors) == bool # noqa: E721 - exit_status, out, error = os_ops.exec_command(args, verbose=True) + exit_status, out, error = os_ops.exec_command(args, verbose=True, ignore_errors=ignore_errors) # decode result out = '' if not out else out if isinstance(out, bytes): From 09976aea2d9317f3a2ea2afb4d83267c253e9650 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Fri, 28 Feb 2025 13:21:50 +0300 Subject: [PATCH 15/25] PostgresNode::_try_shutdown is rewrited (normalization) --- testgres/node.py | 107 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 32 deletions(-) diff --git a/testgres/node.py b/testgres/node.py index 56899b90..cb5862cb 100644 --- a/testgres/node.py +++ b/testgres/node.py @@ -338,41 +338,84 @@ def version(self): return self._pg_version def _try_shutdown(self, max_attempts, with_force=False): + assert type(max_attempts) == int # noqa: E721 + assert type(with_force) == bool # noqa: E721 + assert max_attempts > 0 + attempts = 0 + + # try stopping server N times + while attempts < max_attempts: + attempts += 1 + try: + self.stop() + except ExecUtilException: + continue # one more time + except Exception: + eprint('cannot stop node {}'.format(self.name)) + break + + return # OK + + # If force stopping is enabled and PID is valid + if not with_force: + return False + node_pid = self.pid + assert node_pid is not None + assert type(node_pid) == int # noqa: E721 - if node_pid > 0: - # try stopping server N times - while attempts < max_attempts: - try: - self.stop() - break # OK - except ExecUtilException: - pass # one more time - except Exception: - eprint('cannot stop node {}'.format(self.name)) - break - - attempts += 1 - - # If force stopping is enabled and PID is valid - if with_force and node_pid != 0: - # If we couldn't stop the node - p_status_output = self.os_ops.exec_command(cmd=f'ps -o pid= -p {node_pid}', shell=True, ignore_errors=True).decode('utf-8') - if self.status() != NodeStatus.Stopped and p_status_output and str(node_pid) in p_status_output: - try: - eprint(f'Force stopping node {self.name} with PID {node_pid}') - self.os_ops.kill(node_pid, signal.SIGKILL, expect_error=False) - except Exception: - # The node has already stopped - pass - - # Check that node stopped - print only column pid without headers - p_status_output = self.os_ops.exec_command(f'ps -o pid= -p {node_pid}', shell=True, ignore_errors=True).decode('utf-8') - if p_status_output and str(node_pid) in p_status_output: - eprint(f'Failed to stop node {self.name}.') - else: - eprint(f'Node {self.name} has been stopped successfully.') + if node_pid == 0: + return + + # TODO: [2025-02-28] It is really the old ugly code. We have to rewrite it! + + ps_command = ['ps', '-o', 'pid=', '-p', str(node_pid)] + + ps_output = self.os_ops.exec_command(cmd=ps_command, shell=True, ignore_errors=True).decode('utf-8') + assert type(ps_output) == str # noqa: E721 + + if ps_output == "": + return + + if ps_output != str(node_pid): + __class__._throw_bugcheck__unexpected_result_of_ps( + ps_output, + ps_command) + + try: + eprint('Force stopping node {0} with PID {1}'.format(self.name, node_pid)) + self.os_ops.kill(node_pid, signal.SIGKILL, expect_error=False) + except Exception: + # The node has already stopped + pass + + # Check that node stopped - print only column pid without headers + ps_output = self.os_ops.exec_command(cmd=ps_command, shell=True, ignore_errors=True).decode('utf-8') + assert type(ps_output) == str # noqa: E721 + + if ps_output == "": + eprint('Node {0} has been stopped successfully.'.format(self.name)) + return + + if ps_output == str(node_pid): + eprint('Failed to stop node {0}.'.format(self.name)) + return + + __class__._throw_bugcheck__unexpected_result_of_ps( + ps_output, + ps_command) + + @staticmethod + def _throw_bugcheck__unexpected_result_of_ps(result, cmd): + assert type(result) == str # noqa: E721 + assert type(cmd) == list # noqa: E721 + errLines = [] + errLines.append("[BUG CHECK] Unexpected result of command ps:") + errLines.append(result) + errLines.append("-----") + errLines.append("Command line is {0}".format(cmd)) + raise RuntimeError("\n".join(errLines)) def _assign_master(self, master): """NOTE: this is a private method!""" From 0402c4a6145820043bf94c879921d244ddd8d09a Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Fri, 28 Feb 2025 13:59:41 +0300 Subject: [PATCH 16/25] PostgresNode::pid uses the data from "pg_ctl status" output. --- testgres/consts.py | 4 ++ testgres/node.py | 157 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 130 insertions(+), 31 deletions(-) diff --git a/testgres/consts.py b/testgres/consts.py index 98c84af6..89c49ab7 100644 --- a/testgres/consts.py +++ b/testgres/consts.py @@ -35,3 +35,7 @@ # logical replication settings LOGICAL_REPL_MAX_CATCHUP_ATTEMPTS = 60 + +PG_CTL__STATUS__OK = 0 +PG_CTL__STATUS__NODE_IS_STOPPED = 3 +PG_CTL__STATUS__BAD_DATADIR = 4 diff --git a/testgres/node.py b/testgres/node.py index 6250fd55..744c5516 100644 --- a/testgres/node.py +++ b/testgres/node.py @@ -49,7 +49,9 @@ RECOVERY_CONF_FILE, \ PG_LOG_FILE, \ UTILS_LOG_FILE, \ - PG_PID_FILE + PG_CTL__STATUS__OK, \ + PG_CTL__STATUS__NODE_IS_STOPPED, \ + PG_CTL__STATUS__BAD_DATADIR \ from .consts import \ MAX_LOGICAL_REPLICATION_WORKERS, \ @@ -132,9 +134,6 @@ class PostgresNode(object): # a max number of node start attempts _C_MAX_START_ATEMPTS = 5 - # a max number of read pid file attempts - _C_MAX_GET_PID_ATEMPTS = 5 - def __init__(self, name=None, base_dir=None, port=None, conn_params: ConnectionParams = ConnectionParams(), bin_dir=None, prefix=None): """ @@ -211,40 +210,136 @@ def pid(self): Return postmaster's PID if node is running, else 0. """ - nAttempt = 0 - pid_file = os.path.join(self.data_dir, PG_PID_FILE) - pid_s: str = None + self__data_dir = self.data_dir + + _params = [ + self._get_bin_path('pg_ctl'), + "-D", self__data_dir, + "status" + ] # yapf: disable + + status_code, out, error = execute_utility2( + self.os_ops, + _params, + self.utils_log_file, + verbose=True, + ignore_errors=True) + + assert type(status_code) == int # noqa: E721 + assert type(out) == str # noqa: E721 + assert type(error) == str # noqa: E721 + + # ----------------- + if status_code == PG_CTL__STATUS__NODE_IS_STOPPED: + return 0 + + # ----------------- + if status_code == PG_CTL__STATUS__BAD_DATADIR: + return 0 + + # ----------------- + if status_code != PG_CTL__STATUS__OK: + errMsg = "Getting of a node status [data_dir is {0}] failed.".format(self__data_dir) + + raise ExecUtilException( + message=errMsg, + command=_params, + exit_code=status_code, + out=out, + error=error, + ) + + # ----------------- + assert status_code == PG_CTL__STATUS__OK + + if out == "": + __class__._throw_error__pg_ctl_returns_an_empty_string( + _params + ) + + C_PID_PREFIX = "(PID: " + + i = out.find(C_PID_PREFIX) + + if i == -1: + __class__._throw_error__pg_ctl_returns_an_unexpected_string( + out, + _params + ) + + assert i > 0 + assert i < len(out) + assert len(C_PID_PREFIX) <= len(out) + assert i <= len(out) - len(C_PID_PREFIX) + + i += len(C_PID_PREFIX) + start_pid_s = i + while True: - if nAttempt == __class__._C_MAX_GET_PID_ATEMPTS: - errMsg = "Can't read postmaster pid file [{0}].".format(pid_file) - raise Exception(errMsg) + if i == len(out): + __class__._throw_error__pg_ctl_returns_an_unexpected_string( + out, + _params + ) - nAttempt += 1 + ch = out[i] - s1 = self.status() - if s1 != NodeStatus.Running: - return 0 + if ch == ")": + break - try: - lines = self.os_ops.readlines(pid_file) - except Exception: - s2 = self.status() - if s2 == NodeStatus.Running: - raise - return 0 - - assert lines is not None # [2025-02-27] OK? - assert type(lines) == list # noqa: E721 - if len(lines) == 0: + if ch.isdigit(): + i += 1 continue - pid_s = lines[0] - assert type(pid_s) == str # noqa: E721 - if len(pid_s) == 0: - continue + __class__._throw_error__pg_ctl_returns_an_unexpected_string( + out, + _params + ) + assert False + + if i == start_pid_s: + __class__._throw_error__pg_ctl_returns_an_unexpected_string( + out, + _params + ) - pid = int(pid_s) - return pid + # TODO: Let's verify a length of pid string. + + pid = int(out[start_pid_s:i]) + + if pid == 0: + __class__._throw_error__pg_ctl_returns_a_zero_pid( + out, + _params + ) + + assert pid != 0 + return pid + + @staticmethod + def _throw_error__pg_ctl_returns_an_empty_string(_params): + errLines = [] + errLines.append("Utility pg_ctl returns empty string.") + errLines.append("Command line is {0}".format(_params)) + raise RuntimeError("\n".join(errLines)) + + @staticmethod + def _throw_error__pg_ctl_returns_an_unexpected_string(out, _params): + errLines = [] + errLines.append("Utility pg_ctl returns an unexpected string:") + errLines.append(out) + errLines.append("------------") + errLines.append("Command line is {0}".format(_params)) + raise RuntimeError("\n".join(errLines)) + + @staticmethod + def _throw_error__pg_ctl_returns_a_zero_pid(out, _params): + errLines = [] + errLines.append("Utility pg_ctl returns a zero pid. Output string is:") + errLines.append(out) + errLines.append("------------") + errLines.append("Command line is {0}".format(_params)) + raise RuntimeError("\n".join(errLines)) @property def auxiliary_pids(self): From 45d2b176dc618156312639e41f09c27473266a1f Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Fri, 28 Feb 2025 16:02:54 +0300 Subject: [PATCH 17/25] PostgresNode::_try_shutdown is correct (return None) This method returns nothing (None). --- testgres/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testgres/node.py b/testgres/node.py index cb5862cb..5626e2fe 100644 --- a/testgres/node.py +++ b/testgres/node.py @@ -359,7 +359,7 @@ def _try_shutdown(self, max_attempts, with_force=False): # If force stopping is enabled and PID is valid if not with_force: - return False + return node_pid = self.pid assert node_pid is not None From 619fefa9432f023b48c0399ff7533f3f32965864 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Fri, 28 Feb 2025 20:05:08 +0300 Subject: [PATCH 18/25] [RemoteOperations] A call of mktemp is fixed When we define a template we have to use "-t" option. It forces mktemp to return a path instead name. The following methods of RemoteOperations are fixed: - mkdtemp - mkstemp --- testgres/operations/remote_ops.py | 46 +++++++++++++++++++------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/testgres/operations/remote_ops.py b/testgres/operations/remote_ops.py index 51f5b2e8..2a4e5c78 100644 --- a/testgres/operations/remote_ops.py +++ b/testgres/operations/remote_ops.py @@ -247,32 +247,42 @@ def mkdtemp(self, prefix=None): - prefix (str): The prefix of the temporary directory name. """ if prefix: - command = ["ssh"] + self.ssh_args + [self.ssh_dest, f"mktemp -d {prefix}XXXXX"] + command = ["mktemp", "-d", "-t", prefix + "XXXXX"] else: - command = ["ssh"] + self.ssh_args + [self.ssh_dest, "mktemp -d"] + command = ["mktemp", "-d"] - result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + exit_status, result, error = self.exec_command(command, verbose=True, encoding=get_default_encoding(), ignore_errors=True) - if result.returncode == 0: - temp_dir = result.stdout.strip() - if not os.path.isabs(temp_dir): - temp_dir = os.path.join('/home', self.username, temp_dir) - return temp_dir - else: - raise ExecUtilException(f"Could not create temporary directory. Error: {result.stderr}") + assert type(result) == str # noqa: E721 + assert type(error) == str # noqa: E721 + + if exit_status != 0: + raise ExecUtilException("Could not create temporary directory. Error code: {0}. Error message: {1}".format(exit_status, error)) + + temp_dir = result.strip() + return temp_dir def mkstemp(self, prefix=None): + """ + Creates a temporary file in the remote server. + Args: + - prefix (str): The prefix of the temporary directory name. + """ if prefix: - temp_dir = self.exec_command("mktemp {}XXXXX".format(prefix), encoding=get_default_encoding()) + command = ["mktemp", "-t", prefix + "XXXXX"] else: - temp_dir = self.exec_command("mktemp", encoding=get_default_encoding()) + command = ["mktemp"] - if temp_dir: - if not os.path.isabs(temp_dir): - temp_dir = os.path.join('/home', self.username, temp_dir.strip()) - return temp_dir - else: - raise ExecUtilException("Could not create temporary directory.") + exit_status, result, error = self.exec_command(command, verbose=True, encoding=get_default_encoding(), ignore_errors=True) + + assert type(result) == str # noqa: E721 + assert type(error) == str # noqa: E721 + + if exit_status != 0: + raise ExecUtilException("Could not create temporary file. Error code: {0}. Error message: {1}".format(exit_status, error)) + + temp_file = result.strip() + return temp_file def copytree(self, src, dst): if not os.path.isabs(dst): From 1309442d4be3bf3821b706892231fe36995d7c2a Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Sat, 1 Mar 2025 12:01:12 +0300 Subject: [PATCH 19/25] Total refactoring of os_ops::execute_command Main - We check only an exit code to detect an error. - If someone utility returns a result through an exit code, a caller side should set ignore_errors=true and process this case itself. - If expect_error is true and no errors occurred, we raise an InvalidOperationException. --- testgres/operations/local_ops.py | 35 +++++----- testgres/operations/raise_error.py | 47 ++++--------- testgres/operations/remote_ops.py | 102 +++++++++++++++++++---------- testgres/utils.py | 13 ++-- tests/test_local.py | 5 +- tests/test_remote.py | 10 +-- tests/test_simple_remote.py | 43 ++++++------ 7 files changed, 134 insertions(+), 121 deletions(-) diff --git a/testgres/operations/local_ops.py b/testgres/operations/local_ops.py index 91070fe7..9828a45e 100644 --- a/testgres/operations/local_ops.py +++ b/testgres/operations/local_ops.py @@ -23,20 +23,6 @@ from distutils import rmtree CMD_TIMEOUT_SEC = 60 -error_markers = [b'error', b'Permission denied', b'fatal'] -err_out_markers = [b'Failure'] - - -def has_errors(output=None, error=None): - if output: - if isinstance(output, str): - output = output.encode(get_default_encoding()) - return any(marker in output for marker in err_out_markers) - if error: - if isinstance(error, str): - error = error.encode(get_default_encoding()) - return any(marker in error for marker in error_markers) - return False class LocalOperations(OsOperations): @@ -134,19 +120,28 @@ def exec_command(self, cmd, wait_exit=False, verbose=False, expect_error=False, process, output, error = self._run_command(cmd, shell, input, stdin, stdout, stderr, get_process, timeout, encoding) if get_process: return process - if not ignore_errors and ((process.returncode != 0 or has_errors(output=output, error=error)) and not expect_error): + + if expect_error: + if process.returncode == 0: + raise InvalidOperationException("We expected an execution error.") + elif ignore_errors: + pass + elif process.returncode == 0: + pass + else: + assert not expect_error + assert not ignore_errors + assert process.returncode != 0 RaiseError.UtilityExitedWithNonZeroCode( cmd=cmd, exit_code=process.returncode, - msg_arg=error or output, error=error, - out=output - ) + out=output) if verbose: return process.returncode, output, error - else: - return output + + return output # Environment setup def environ(self, var_name): diff --git a/testgres/operations/raise_error.py b/testgres/operations/raise_error.py index 6031b238..bb2945e6 100644 --- a/testgres/operations/raise_error.py +++ b/testgres/operations/raise_error.py @@ -1,50 +1,27 @@ from ..exceptions import ExecUtilException -from .helpers import Helpers class RaiseError: @staticmethod - def UtilityExitedWithNonZeroCode(cmd, exit_code, msg_arg, error, out): + def UtilityExitedWithNonZeroCode(cmd, exit_code, error, out): assert type(exit_code) == int # noqa: E721 - msg_arg_s = __class__._TranslateDataIntoString(msg_arg).strip() - assert type(msg_arg_s) == str # noqa: E721 - - if msg_arg_s == "": - msg_arg_s = "#no_error_message" - - message = "Utility exited with non-zero code. Error: `" + msg_arg_s + "`" raise ExecUtilException( - message=message, + message="Utility exited with non-zero code.", command=cmd, exit_code=exit_code, out=out, error=error) @staticmethod - def _TranslateDataIntoString(data): - if type(data) == bytes: # noqa: E721 - return __class__._TranslateDataIntoString__FromBinary(data) - - return str(data) - - @staticmethod - def _TranslateDataIntoString__FromBinary(data): - assert type(data) == bytes # noqa: E721 - - try: - return data.decode(Helpers.GetDefaultEncoding()) - except UnicodeDecodeError: - pass - - return "#cannot_decode_text" - - @staticmethod - def _BinaryIsASCII(data): - assert type(data) == bytes # noqa: E721 - - for b in data: - if not (b >= 0 and b <= 127): - return False + def CommandExecutionError(cmd, exit_code, message, error, out): + assert type(exit_code) == int # noqa: E721 + assert type(message) == str # noqa: E721 + assert message != "" - return True + raise ExecUtilException( + message=message, + command=cmd, + exit_code=exit_code, + out=out, + error=error) diff --git a/testgres/operations/remote_ops.py b/testgres/operations/remote_ops.py index 2a4e5c78..a827eac4 100644 --- a/testgres/operations/remote_ops.py +++ b/testgres/operations/remote_ops.py @@ -100,41 +100,39 @@ def exec_command(self, cmd, wait_exit=False, verbose=False, expect_error=False, return process try: - result, error = process.communicate(input=input_prepared, timeout=timeout) + output, error = process.communicate(input=input_prepared, timeout=timeout) except subprocess.TimeoutExpired: process.kill() raise ExecUtilException("Command timed out after {} seconds.".format(timeout)) - exit_status = process.returncode - - assert type(result) == bytes # noqa: E721 + assert type(output) == bytes # noqa: E721 assert type(error) == bytes # noqa: E721 - if not error: - error_found = False - else: - error_found = exit_status != 0 or any( - marker in error for marker in [b'error', b'Permission denied', b'fatal', b'No such file or directory'] - ) - - assert type(error_found) == bool # noqa: E721 - if encoding: - result = result.decode(encoding) + output = output.decode(encoding) error = error.decode(encoding) - if not ignore_errors and error_found and not expect_error: + if expect_error: + if process.returncode == 0: + raise InvalidOperationException("We expected an execution error.") + elif ignore_errors: + pass + elif process.returncode == 0: + pass + else: + assert not expect_error + assert not ignore_errors + assert process.returncode != 0 RaiseError.UtilityExitedWithNonZeroCode( cmd=cmd, - exit_code=exit_status, - msg_arg=error, + exit_code=process.returncode, error=error, - out=result) + out=output) if verbose: - return exit_status, result, error - else: - return result + return process.returncode, output, error + + return output # Environment setup def environ(self, var_name: str) -> str: @@ -165,8 +163,30 @@ def find_executable(self, executable): def is_executable(self, file): # Check if the file is executable - is_exec = self.exec_command("test -x {} && echo OK".format(file)) - return is_exec == b"OK\n" + command = ["test", "-x", file] + + exit_status, output, error = self.exec_command(cmd=command, encoding=get_default_encoding(), ignore_errors=True, verbose=True) + + assert type(output) == str # noqa: E721 + assert type(error) == str # noqa: E721 + + if exit_status == 0: + return True + + if exit_status == 1: + return False + + errMsg = "Test operation returns an unknown result code: {0}. File name is [{1}].".format( + exit_status, + file) + + RaiseError.UtilityExitedWithNonZeroCode( + cmd=command, + exit_code=exit_status, + msg_arg=errMsg, + error=error, + out=output + ) def set_env(self, var_name: str, var_val: str): """ @@ -251,15 +271,21 @@ def mkdtemp(self, prefix=None): else: command = ["mktemp", "-d"] - exit_status, result, error = self.exec_command(command, verbose=True, encoding=get_default_encoding(), ignore_errors=True) + exec_exitcode, exec_output, exec_error = self.exec_command(command, verbose=True, encoding=get_default_encoding(), ignore_errors=True) - assert type(result) == str # noqa: E721 - assert type(error) == str # noqa: E721 + assert type(exec_exitcode) == int # noqa: E721 + assert type(exec_output) == str # noqa: E721 + assert type(exec_error) == str # noqa: E721 - if exit_status != 0: - raise ExecUtilException("Could not create temporary directory. Error code: {0}. Error message: {1}".format(exit_status, error)) + if exec_exitcode != 0: + RaiseError.CommandExecutionError( + cmd=command, + exit_code=exec_exitcode, + message="Could not create temporary directory.", + error=exec_error, + out=exec_output) - temp_dir = result.strip() + temp_dir = exec_output.strip() return temp_dir def mkstemp(self, prefix=None): @@ -273,15 +299,21 @@ def mkstemp(self, prefix=None): else: command = ["mktemp"] - exit_status, result, error = self.exec_command(command, verbose=True, encoding=get_default_encoding(), ignore_errors=True) + exec_exitcode, exec_output, exec_error = self.exec_command(command, verbose=True, encoding=get_default_encoding(), ignore_errors=True) - assert type(result) == str # noqa: E721 - assert type(error) == str # noqa: E721 + assert type(exec_exitcode) == int # noqa: E721 + assert type(exec_output) == str # noqa: E721 + assert type(exec_error) == str # noqa: E721 - if exit_status != 0: - raise ExecUtilException("Could not create temporary file. Error code: {0}. Error message: {1}".format(exit_status, error)) + if exec_exitcode != 0: + RaiseError.CommandExecutionError( + cmd=command, + exit_code=exec_exitcode, + message="Could not create temporary file.", + error=exec_error, + out=exec_output) - temp_file = result.strip() + temp_file = exec_output.strip() return temp_file def copytree(self, src, dst): diff --git a/testgres/utils.py b/testgres/utils.py index 76d42b02..093eaff6 100644 --- a/testgres/utils.py +++ b/testgres/utils.py @@ -18,6 +18,7 @@ from .config import testgres_config as tconf from .operations.os_ops import OsOperations from .operations.remote_ops import RemoteOperations +from .operations.helpers import Helpers as OsHelpers # rows returned by PG_CONFIG _pg_config_data = {} @@ -79,13 +80,13 @@ def execute_utility2(os_ops: OsOperations, args, logfile=None, verbose=False, ig assert type(verbose) == bool # noqa: E721 assert type(ignore_errors) == bool # noqa: E721 - exit_status, out, error = os_ops.exec_command(args, verbose=True, ignore_errors=ignore_errors) - # decode result + exit_status, out, error = os_ops.exec_command( + args, + verbose=True, + ignore_errors=ignore_errors, + encoding=OsHelpers.GetDefaultEncoding()) + out = '' if not out else out - if isinstance(out, bytes): - out = out.decode('utf-8') - if isinstance(error, bytes): - error = error.decode('utf-8') # write new log entry if possible if logfile: diff --git a/tests/test_local.py b/tests/test_local.py index 60a96c18..a16a8e6b 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -40,10 +40,11 @@ def test_exec_command_failure(self): try: self.operations.exec_command(cmd, wait_exit=True, shell=True) except ExecUtilException as e: - error = e.message + assert e.message == "Utility exited with non-zero code." + assert type(e.error) == bytes # noqa: E721 + assert e.error.strip() == b"/bin/sh: 1: nonexistent_command: not found" break raise Exception("We wait an exception!") - assert error == "Utility exited with non-zero code. Error: `/bin/sh: 1: nonexistent_command: not found`" def test_exec_command_failure__expect_error(self): """ diff --git a/tests/test_remote.py b/tests/test_remote.py index 8b167e9f..5af72958 100755 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -38,10 +38,11 @@ def test_exec_command_failure(self): try: self.operations.exec_command(cmd, verbose=True, wait_exit=True) except ExecUtilException as e: - error = e.message + assert e.message == "Utility exited with non-zero code." + assert type(e.error) == bytes # noqa: E721 + assert e.error.strip() == b"bash: line 1: nonexistent_command: command not found" break raise Exception("We wait an exception!") - assert error == 'Utility exited with non-zero code. Error: `bash: line 1: nonexistent_command: command not found`' def test_exec_command_failure__expect_error(self): """ @@ -108,10 +109,11 @@ def test_makedirs_and_rmdirs_failure(self): try: self.operations.rmdirs(path, verbose=True) except ExecUtilException as e: - error = e.message + assert e.message == "Utility exited with non-zero code." + assert type(e.error) == bytes # noqa: E721 + assert e.error.strip() == b"rm: cannot remove '/root/test_dir': Permission denied" break raise Exception("We wait an exception!") - assert error == "Utility exited with non-zero code. Error: `rm: cannot remove '/root/test_dir': Permission denied`" def test_listdir(self): """ diff --git a/tests/test_simple_remote.py b/tests/test_simple_remote.py index d4a28a2b..74b10635 100755 --- a/tests/test_simple_remote.py +++ b/tests/test_simple_remote.py @@ -178,27 +178,32 @@ def test_init__unk_LANG_and_LC_CTYPE(self): assert os.environ.get("LC_CTYPE") == unkData[1] assert not ("LC_COLLATE" in os.environ.keys()) - while True: + assert os.getenv('LANG') == unkData[0] + assert os.getenv('LANGUAGE') is None + assert os.getenv('LC_CTYPE') == unkData[1] + assert os.getenv('LC_COLLATE') is None + + exc: ExecUtilException = None + with __class__.helper__get_node() as node: try: - with __class__.helper__get_node(): - pass - except ExecUtilException as e: - # - # Example of an error message: - # - # warning: setlocale: LC_CTYPE: cannot change locale (UNKNOWN_CTYPE): No such file or directory - # postgres (PostgreSQL) 14.12 - # - errMsg = str(e) - - logging.info("Error message is: {0}".format(errMsg)) - - assert "LC_CTYPE" in errMsg - assert unkData[1] in errMsg - assert "warning: setlocale: LC_CTYPE: cannot change locale (" + unkData[1] + "): No such file or directory" in errMsg - assert ("postgres" in errMsg) or ("PostgreSQL" in errMsg) - break + node.init() # IT RAISES! + except InitNodeException as e: + exc = e.__cause__ + assert exc is not None + assert isinstance(exc, ExecUtilException) + + if exc is None: raise Exception("We expected an error!") + + assert isinstance(exc, ExecUtilException) + + errMsg = str(exc) + logging.info("Error message is {0}: {1}".format(type(exc).__name__, errMsg)) + + assert "warning: setlocale: LC_CTYPE: cannot change locale (" + unkData[1] + ")" in errMsg + assert "initdb: error: invalid locale settings; check LANG and LC_* environment variables" in errMsg + continue + finally: __class__.helper__restore_envvar("LANG", prev_LANG) __class__.helper__restore_envvar("LANGUAGE", prev_LANGUAGE) From e245e2303a0b66f9cdf9568bcc5e72cfffc7cd3f Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Sat, 1 Mar 2025 17:06:31 +0300 Subject: [PATCH 20/25] Dockerfile--ubuntu-24_04.tmpl is updated The folder "home/postgres" is not required now. --- Dockerfile--ubuntu-24_04.tmpl | 3 --- 1 file changed, 3 deletions(-) diff --git a/Dockerfile--ubuntu-24_04.tmpl b/Dockerfile--ubuntu-24_04.tmpl index 38a43570..e0ca9679 100644 --- a/Dockerfile--ubuntu-24_04.tmpl +++ b/Dockerfile--ubuntu-24_04.tmpl @@ -35,9 +35,6 @@ RUN ssh-keygen -A # It enables execution of "sudo service ssh start" without password RUN sh -c "echo postgres ALL=NOPASSWD:/usr/sbin/service ssh start" >> /etc/sudoers -# It requires for test those work through SSH -RUN mkdir -p /home/postgres -RUN chown postgres:postgres /home/postgres USER postgres From d843df62f1d038905dd6a12d420edc9be670d4bf Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Sat, 1 Mar 2025 21:53:19 +0300 Subject: [PATCH 21/25] The old behaviour of RaiseError.UtilityExitedWithNonZeroCode is restored Let's rollback the new code to avoid problems with probackup2' tests. --- testgres/operations/local_ops.py | 1 + testgres/operations/raise_error.py | 34 ++++++++++++++++++++++++++++-- testgres/operations/remote_ops.py | 3 ++- tests/test_local.py | 2 +- tests/test_remote.py | 4 ++-- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/testgres/operations/local_ops.py b/testgres/operations/local_ops.py index 9828a45e..51003174 100644 --- a/testgres/operations/local_ops.py +++ b/testgres/operations/local_ops.py @@ -135,6 +135,7 @@ def exec_command(self, cmd, wait_exit=False, verbose=False, expect_error=False, RaiseError.UtilityExitedWithNonZeroCode( cmd=cmd, exit_code=process.returncode, + msg_arg=error or output, error=error, out=output) diff --git a/testgres/operations/raise_error.py b/testgres/operations/raise_error.py index bb2945e6..0d14be5a 100644 --- a/testgres/operations/raise_error.py +++ b/testgres/operations/raise_error.py @@ -1,13 +1,22 @@ from ..exceptions import ExecUtilException +from .helpers import Helpers class RaiseError: @staticmethod - def UtilityExitedWithNonZeroCode(cmd, exit_code, error, out): + def UtilityExitedWithNonZeroCode(cmd, exit_code, msg_arg, error, out): assert type(exit_code) == int # noqa: E721 + msg_arg_s = __class__._TranslateDataIntoString(msg_arg) + assert type(msg_arg_s) == str # noqa: E721 + + msg_arg_s = msg_arg_s.strip() + if msg_arg_s == "": + msg_arg_s = "#no_error_message" + + message = "Utility exited with non-zero code (" + str(exit_code) + "). Error: `" + msg_arg_s + "`" raise ExecUtilException( - message="Utility exited with non-zero code.", + message=message, command=cmd, exit_code=exit_code, out=out, @@ -25,3 +34,24 @@ def CommandExecutionError(cmd, exit_code, message, error, out): exit_code=exit_code, out=out, error=error) + + @staticmethod + def _TranslateDataIntoString(data): + if data is None: + return "" + + if type(data) == bytes: # noqa: E721 + return __class__._TranslateDataIntoString__FromBinary(data) + + return str(data) + + @staticmethod + def _TranslateDataIntoString__FromBinary(data): + assert type(data) == bytes # noqa: E721 + + try: + return data.decode(Helpers.GetDefaultEncoding()) + except UnicodeDecodeError: + pass + + return "#cannot_decode_text" diff --git a/testgres/operations/remote_ops.py b/testgres/operations/remote_ops.py index a827eac4..dc392bee 100644 --- a/testgres/operations/remote_ops.py +++ b/testgres/operations/remote_ops.py @@ -126,6 +126,7 @@ def exec_command(self, cmd, wait_exit=False, verbose=False, expect_error=False, RaiseError.UtilityExitedWithNonZeroCode( cmd=cmd, exit_code=process.returncode, + msg_arg=error, error=error, out=output) @@ -180,7 +181,7 @@ def is_executable(self, file): exit_status, file) - RaiseError.UtilityExitedWithNonZeroCode( + RaiseError.CommandExecutionError( cmd=command, exit_code=exit_status, msg_arg=errMsg, diff --git a/tests/test_local.py b/tests/test_local.py index a16a8e6b..ee5e19a0 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -40,7 +40,7 @@ def test_exec_command_failure(self): try: self.operations.exec_command(cmd, wait_exit=True, shell=True) except ExecUtilException as e: - assert e.message == "Utility exited with non-zero code." + assert e.message == "Utility exited with non-zero code (127). Error: `/bin/sh: 1: nonexistent_command: not found`" assert type(e.error) == bytes # noqa: E721 assert e.error.strip() == b"/bin/sh: 1: nonexistent_command: not found" break diff --git a/tests/test_remote.py b/tests/test_remote.py index 5af72958..61d98768 100755 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -38,7 +38,7 @@ def test_exec_command_failure(self): try: self.operations.exec_command(cmd, verbose=True, wait_exit=True) except ExecUtilException as e: - assert e.message == "Utility exited with non-zero code." + assert e.message == "Utility exited with non-zero code (127). Error: `bash: line 1: nonexistent_command: command not found`" assert type(e.error) == bytes # noqa: E721 assert e.error.strip() == b"bash: line 1: nonexistent_command: command not found" break @@ -109,7 +109,7 @@ def test_makedirs_and_rmdirs_failure(self): try: self.operations.rmdirs(path, verbose=True) except ExecUtilException as e: - assert e.message == "Utility exited with non-zero code." + assert e.message == "Utility exited with non-zero code (1). Error: `rm: cannot remove '/root/test_dir': Permission denied`" assert type(e.error) == bytes # noqa: E721 assert e.error.strip() == b"rm: cannot remove '/root/test_dir': Permission denied" break From 25c7c0ff10b8ffea051c706096759cc8c6829dfb Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Sat, 1 Mar 2025 22:34:05 +0300 Subject: [PATCH 22/25] TestRemoteOperations::test_is_executable_true is corrected Let's test a real pg_config. --- tests/test_remote.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_remote.py b/tests/test_remote.py index 8b167e9f..e457de07 100755 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -8,7 +8,9 @@ from ..testgres import ExecUtilException from ..testgres import InvalidOperationException from ..testgres import RemoteOperations +from ..testgres import LocalOperations from ..testgres import ConnectionParams +from ..testgres import utils as testgres_utils class TestRemoteOperations: @@ -59,7 +61,11 @@ def test_is_executable_true(self): """ Test is_executable for an existing executable. """ - cmd = os.getenv('PG_CONFIG') + local_ops = LocalOperations() + cmd = testgres_utils.get_bin_path2(local_ops, "pg_config") + cmd = local_ops.exec_command([cmd, "--bindir"], encoding="utf-8") + cmd = cmd.rstrip() + cmd = os.path.join(cmd, "pg_config") response = self.operations.is_executable(cmd) assert response is True From 5cec260cbc276af97f8cea955a3f575270ddc5f4 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Sun, 2 Mar 2025 13:33:54 +0300 Subject: [PATCH 23/25] xxx::test_logging is corrected (local, remote) - these tests configure logging wrong and create the conflicts with root logger - these tests (local and remote) conflict with each other --- tests/test_simple.py | 142 +++++++++++++++++++++------------- tests/test_simple_remote.py | 147 +++++++++++++++++++++++------------- 2 files changed, 184 insertions(+), 105 deletions(-) diff --git a/tests/test_simple.py b/tests/test_simple.py index 6c433cd4..37c3db44 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -8,8 +8,8 @@ import pytest import psutil import platform - -import logging.config +import logging +import uuid from contextlib import contextmanager from shutil import rmtree @@ -718,55 +718,95 @@ def test_poll_query_until(self): node.poll_query_until('select true') def test_logging(self): - logfile = tempfile.NamedTemporaryFile('w', delete=True) - - log_conf = { - 'version': 1, - 'handlers': { - 'file': { - 'class': 'logging.FileHandler', - 'filename': logfile.name, - 'formatter': 'base_format', - 'level': logging.DEBUG, - }, - }, - 'formatters': { - 'base_format': { - 'format': '%(node)-5s: %(message)s', - }, - }, - 'root': { - 'handlers': ('file', ), - 'level': 'DEBUG', - }, - } - - logging.config.dictConfig(log_conf) - - with scoped_config(use_python_logging=True): - node_name = 'master' - - with get_new_node(name=node_name) as master: - master.init().start() - - # execute a dummy query a few times - for i in range(20): - master.execute('select 1') - time.sleep(0.01) - - # let logging worker do the job - time.sleep(0.1) - - # check that master's port is found - with open(logfile.name, 'r') as log: - lines = log.readlines() - assert (any(node_name in s for s in lines)) - - # test logger after stop/start/restart - master.stop() - master.start() - master.restart() - assert (master._logger.is_alive()) + C_MAX_ATTEMPTS = 50 + # This name is used for testgres logging, too. + C_NODE_NAME = "testgres_tests." + __class__.__name__ + "test_logging-master-" + uuid.uuid4().hex + + logging.info("Node name is [{0}]".format(C_NODE_NAME)) + + with tempfile.NamedTemporaryFile('w', delete=True) as logfile: + formatter = logging.Formatter(fmt="%(node)-5s: %(message)s") + handler = logging.FileHandler(filename=logfile.name) + handler.formatter = formatter + logger = logging.getLogger(C_NODE_NAME) + assert logger is not None + assert len(logger.handlers) == 0 + + try: + # It disables to log on the root level + logger.propagate = False + logger.addHandler(handler) + + with scoped_config(use_python_logging=True): + with get_new_node(name=C_NODE_NAME) as master: + logging.info("Master node is initilizing") + master.init() + + logging.info("Master node is starting") + master.start() + + logging.info("Dummy query is executed a few times") + for _ in range(20): + master.execute('select 1') + time.sleep(0.01) + + # let logging worker do the job + time.sleep(0.1) + + logging.info("Master node log file is checking") + nAttempt = 0 + + while True: + assert nAttempt <= C_MAX_ATTEMPTS + if nAttempt == C_MAX_ATTEMPTS: + raise Exception("Test failed!") + + # let logging worker do the job + time.sleep(0.1) + + nAttempt += 1 + + logging.info("Attempt {0}".format(nAttempt)) + + # check that master's port is found + with open(logfile.name, 'r') as log: + lines = log.readlines() + + assert lines is not None + assert type(lines) == list # noqa: E721 + + def LOCAL__test_lines(): + for s in lines: + if any(C_NODE_NAME in s for s in lines): + logging.info("OK. We found the node_name in a line \"{0}\"".format(s)) + return True + return False + + if LOCAL__test_lines(): + break + + logging.info("Master node log file does not have an expected information.") + continue + + # test logger after stop/start/restart + logging.info("Master node is stopping...") + master.stop() + logging.info("Master node is staring again...") + master.start() + logging.info("Master node is restaring...") + master.restart() + assert (master._logger.is_alive()) + finally: + # It is a hack code to logging cleanup + logging._acquireLock() + assert logging.Logger.manager is not None + assert C_NODE_NAME in logging.Logger.manager.loggerDict.keys() + logging.Logger.manager.loggerDict.pop(C_NODE_NAME, None) + assert not (C_NODE_NAME in logging.Logger.manager.loggerDict.keys()) + assert not (handler in logging._handlers.values()) + logging._releaseLock() + # GO HOME! + return def test_pgbench(self): __class__.helper__skip_test_if_util_not_exist("pgbench") diff --git a/tests/test_simple_remote.py b/tests/test_simple_remote.py index 74b10635..a62085ce 100755 --- a/tests/test_simple_remote.py +++ b/tests/test_simple_remote.py @@ -8,8 +8,8 @@ import six import pytest import psutil - -import logging.config +import logging +import uuid from contextlib import contextmanager @@ -788,56 +788,95 @@ def test_poll_query_until(self): node.poll_query_until('select true') def test_logging(self): - # FAIL - logfile = tempfile.NamedTemporaryFile('w', delete=True) - - log_conf = { - 'version': 1, - 'handlers': { - 'file': { - 'class': 'logging.FileHandler', - 'filename': logfile.name, - 'formatter': 'base_format', - 'level': logging.DEBUG, - }, - }, - 'formatters': { - 'base_format': { - 'format': '%(node)-5s: %(message)s', - }, - }, - 'root': { - 'handlers': ('file',), - 'level': 'DEBUG', - }, - } - - logging.config.dictConfig(log_conf) - - with scoped_config(use_python_logging=True): - node_name = 'master' - - with get_remote_node(name=node_name) as master: - master.init().start() - - # execute a dummy query a few times - for i in range(20): - master.execute('select 1') - time.sleep(0.01) - - # let logging worker do the job - time.sleep(0.1) - - # check that master's port is found - with open(logfile.name, 'r') as log: - lines = log.readlines() - assert (any(node_name in s for s in lines)) - - # test logger after stop/start/restart - master.stop() - master.start() - master.restart() - assert (master._logger.is_alive()) + C_MAX_ATTEMPTS = 50 + # This name is used for testgres logging, too. + C_NODE_NAME = "testgres_tests." + __class__.__name__ + "test_logging-master-" + uuid.uuid4().hex + + logging.info("Node name is [{0}]".format(C_NODE_NAME)) + + with tempfile.NamedTemporaryFile('w', delete=True) as logfile: + formatter = logging.Formatter(fmt="%(node)-5s: %(message)s") + handler = logging.FileHandler(filename=logfile.name) + handler.formatter = formatter + logger = logging.getLogger(C_NODE_NAME) + assert logger is not None + assert len(logger.handlers) == 0 + + try: + # It disables to log on the root level + logger.propagate = False + logger.addHandler(handler) + + with scoped_config(use_python_logging=True): + with __class__.helper__get_node(name=C_NODE_NAME) as master: + logging.info("Master node is initilizing") + master.init() + + logging.info("Master node is starting") + master.start() + + logging.info("Dummy query is executed a few times") + for _ in range(20): + master.execute('select 1') + time.sleep(0.01) + + # let logging worker do the job + time.sleep(0.1) + + logging.info("Master node log file is checking") + nAttempt = 0 + + while True: + assert nAttempt <= C_MAX_ATTEMPTS + if nAttempt == C_MAX_ATTEMPTS: + raise Exception("Test failed!") + + # let logging worker do the job + time.sleep(0.1) + + nAttempt += 1 + + logging.info("Attempt {0}".format(nAttempt)) + + # check that master's port is found + with open(logfile.name, 'r') as log: + lines = log.readlines() + + assert lines is not None + assert type(lines) == list # noqa: E721 + + def LOCAL__test_lines(): + for s in lines: + if any(C_NODE_NAME in s for s in lines): + logging.info("OK. We found the node_name in a line \"{0}\"".format(s)) + return True + return False + + if LOCAL__test_lines(): + break + + logging.info("Master node log file does not have an expected information.") + continue + + # test logger after stop/start/restart + logging.info("Master node is stopping...") + master.stop() + logging.info("Master node is staring again...") + master.start() + logging.info("Master node is restaring...") + master.restart() + assert (master._logger.is_alive()) + finally: + # It is a hack code to logging cleanup + logging._acquireLock() + assert logging.Logger.manager is not None + assert C_NODE_NAME in logging.Logger.manager.loggerDict.keys() + logging.Logger.manager.loggerDict.pop(C_NODE_NAME, None) + assert not (C_NODE_NAME in logging.Logger.manager.loggerDict.keys()) + assert not (handler in logging._handlers.values()) + logging._releaseLock() + # GO HOME! + return def test_pgbench(self): __class__.helper__skip_test_if_util_not_exist("pgbench") @@ -1184,9 +1223,9 @@ def test_child_process_dies(self): break @staticmethod - def helper__get_node(): + def helper__get_node(name=None): assert __class__.sm_conn_params is not None - return get_remote_node(conn_params=__class__.sm_conn_params) + return get_remote_node(name=name, conn_params=__class__.sm_conn_params) @staticmethod def helper__restore_envvar(name, prev_value): From d15e29cb6e4c6207b50599a77621b2e58b2aad69 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Sun, 2 Mar 2025 14:08:27 +0300 Subject: [PATCH 24/25] TEST_FILTER is added --- run_tests.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/run_tests.sh b/run_tests.sh index 5cbbac60..021f9d9f 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -4,6 +4,7 @@ set -eux +if [ -z ${TEST_FILTER+x} ]; then export TEST_FILTER="TestgresTests"; fi # choose python version echo python version is $PYTHON_VERSION @@ -38,19 +39,19 @@ rm -f $COVERAGE_FILE # run tests (PATH) -time coverage run -a -m pytest -l -v -n 4 -k "TestgresTests" +time coverage run -a -m pytest -l -v -n 4 -k "${TEST_FILTER}" # run tests (PG_BIN) time \ PG_BIN=$(pg_config --bindir) \ - coverage run -a -m pytest -l -v -n 4 -k "TestgresTests" + coverage run -a -m pytest -l -v -n 4 -k "${TEST_FILTER}" # run tests (PG_CONFIG) time \ PG_CONFIG=$(pg_config --bindir)/pg_config \ - coverage run -a -m pytest -l -v -n 4 -k "TestgresTests" + coverage run -a -m pytest -l -v -n 4 -k "${TEST_FILTER}" # show coverage From 9329f224f6c1b5bafc5230fd5c5b450297ad05b3 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Sun, 2 Mar 2025 14:09:00 +0300 Subject: [PATCH 25/25] CI on Ubuntu 24.04 runs all the tests --- Dockerfile--ubuntu-24_04.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile--ubuntu-24_04.tmpl b/Dockerfile--ubuntu-24_04.tmpl index e0ca9679..99be5343 100644 --- a/Dockerfile--ubuntu-24_04.tmpl +++ b/Dockerfile--ubuntu-24_04.tmpl @@ -66,4 +66,4 @@ chmod 600 ~/.ssh/authorized_keys; \ echo ----; \ ls -la ~/.ssh/; \ echo ----; \ -PYTHON_VERSION=${PYTHON_VERSION} bash run_tests.sh;" +TEST_FILTER="" PYTHON_VERSION=${PYTHON_VERSION} bash run_tests.sh;"