From 8e5fcfa69052d6477a748020a58af9135d65f22f Mon Sep 17 00:00:00 2001 From: David Steele Date: Fri, 31 Jul 2026 22:32:02 +0700 Subject: [PATCH] Move the vm definitions to test/container.yaml. The definitions move out of common/vm.py into a vm section at the top of test/container.yaml, next to the container revisions that go with them, and are read and checked when the module is imported as they were before. The revisions move under a revision key to make room. The declaration is read with the loader the other declarations use, so a version keeps the text it was written as rather than arriving as a float. A new yaml_map_dict() reads a mapping as a dict where a key is looked up by name, which also reports a key that was written twice instead of silently keeping the last one. The constants that named each vm are gone. A vm is named by the string that --vm accepts, which is how the CI workflows and the documentation build already name them. A name used as a lookup is validated by vm_get(), and a name used in a comparison is protected by branch coverage since a comparison that can never match leaves a branch unhittable. VM_NONE, VM_ALL, and the os base and architecture constants remain because they name kinds rather than vms. VM_LIST is now the vms declared as default, so it is in declaration order. The container build no longer tests a vm by name. What a vm installs and configures is declared instead: valgrind, epel, powertools, python, coverage-python, pg-repo-key, pg-repo-release, dnf-module, and ssh-rsa. The three near identical blocks that added the PostgreSQL repo collapse into one, which removes the uncoverable branch annotation on the fedora branch. Every generated script is unchanged, so no image is rebuilt. The supported PostgreSQL versions are generated from build/postgres.yaml rather than declared a second time. A new bld_pg_version_list() reads just the versions, since a tool that only needs to know which ones are supported has no use for the interface that bld_pg_parse() scans the vendored headers for. The release flag comes with them, so the beta repository is added for whichever installed version has not been released rather than for a hardcoded vm and version. A vm with no container declares only default and coverage-c, since every other key says how a container is built. The os base declared for none was never read, and the os the tests run on with --vm=none is whatever the host is rather than the debian it claimed. The PostgreSQL versions declared for it were never read either, since only an integration test runs against a version and those need a container. A PostgreSQL version installed on a vm must now be one that build/postgres.yaml declares, and a version tested on a vm must be one installed there, both of which were only true by inspection before. --- build/lib/common/yaml.py | 19 ++ build/lib/postgres/parse.py | 12 +- test/container.yaml | 172 +++++++++++++- test/lib/command/vm/build.py | 161 +++++++------ test/lib/common/vm.py | 213 +++++++++++++----- test/src/module/build/common/yaml_test.py | 17 ++ test/src/module/test/command/test/job_test.py | 10 +- .../src/module/test/command/test/list_test.py | 26 +-- .../src/module/test/command/test/test_test.py | 6 +- test/src/module/test/command/vm/build_test.py | 123 ++++++---- test/src/module/test/common/vm_test.py | 167 +++++++++++++- 11 files changed, 715 insertions(+), 211 deletions(-) diff --git a/build/lib/common/yaml.py b/build/lib/common/yaml.py index 38408c01d..e94143600 100644 --- a/build/lib/common/yaml.py +++ b/build/lib/common/yaml.py @@ -75,3 +75,22 @@ def yaml_map_empty(value, name): """Check that a mapping is empty, i.e. the key is a name in a list rather than something with a definition.""" check(isinstance(value, YamlMap) and len(value) == 0, "%s must be an empty map" % name) + + +#################################################################################################################################### +def yaml_map_dict(value, name): + """A mapping as a dict, for a declaration where a key is looked up by name and repeating one is a mistake. + + The loader keeps a repeated key because some declarations mean it, so a declaration that does not has to say so here rather than + silently keeping the last value written.""" + + check(isinstance(value, YamlMap), "%s must be a map" % name) + + result = {} + + for key, entry in value: + check(key not in result, "%s has duplicate key '%s'" % (name, key)) + + result[key] = entry + + return result diff --git a/build/lib/postgres/parse.py b/build/lib/postgres/parse.py index 1bf1e6e0f..c5ff9206e 100644 --- a/build/lib/postgres/parse.py +++ b/build/lib/postgres/parse.py @@ -36,9 +36,13 @@ class BldPg: #################################################################################################################################### -def _version_list(path): - """Parse the version list.""" +def bld_pg_version_list(path_repo): + """Parse the supported versions, oldest first. + Separate from the interface below because the versions are the whole of what the declaration says, so a tool that needs to know + which versions are supported does not also scan the vendored headers for an interface it has no use for.""" + + path = os.path.join(path_repo, "build/postgres.yaml") result = [] for key, value in yaml_load(file_read(path), path): @@ -139,6 +143,4 @@ def bld_pg_parse(path_repo): # Functions are defined as macros, which each interface expands for its own version function_list = _define_list(file_read(os.path.join(path_repo, "src/postgres/interface/version.intern.h"))) - path_yaml = os.path.join(path_repo, "build/postgres.yaml") - - return BldPg(_version_list(path_yaml), type_list, define_list, function_list) + return BldPg(bld_pg_version_list(path_repo), type_list, define_list, function_list) diff --git a/test/container.yaml b/test/container.yaml index 26354e9df..9ba334777 100644 --- a/test/container.yaml +++ b/test/container.yaml @@ -1,3 +1,172 @@ +# ********************************************************************************************************************************** +# Vm Definitions +# +# The vms tests can run on, the PostgreSQL versions each one provides, and what the container build needs to know about them. A vm +# name is what --vm accepts, e.g. u24 for Ubuntu 24.04. The definitions are declared here rather than in the code so that a vm and +# the container revision that goes with it are read from the same file. +# +# The definitions are checked when they are read: every supported PostgreSQL version must be tested on exactly one default vm and at +# least one default vm must collect C coverage. Without the check a new version could be added and silently never tested. +# +# - 'default' marks a vm that --vm=all selects +# - 'image' is the image the container is built from, and a vm without one has no container, i.e. it runs on the host +# - 'os-base' is the os the container is built on, which decides its package manager and where PostgreSQL is installed +# - 'db' is the PostgreSQL versions installed and 'db-test' the ones integration tests run against, which defaults to all of them +# - the rest is what the container build installs or configures, and each defaults to false or unset +# +# A vm with no container declares only 'default' and 'coverage-c'. Everything else says how a container is built, and the os the +# tests run on is then whatever the host is rather than anything that can be declared here. +# ********************************************************************************************************************************** +vm: + # No container, i.e. run directly on the host + none: + coverage-c: true + + # Alpine 3.21 + a321: + default: true + os-base: alpine + image: alpine:3.21 + db: + - 15 + - 16 + - 17 + db-test: + - 16 + pg-repo: false + ssh-rsa: true + + # Alpine 3.24 + a324: + default: true + os-base: alpine + image: alpine:3.24 + db: + - 16 + - 17 + - 18 + db-test: + - 17 + pg-repo: false + + # Debian 12 + d12: + default: true + os-base: debian + image: debian:12 + db: + - 15 + pg-repo: false + valgrind: false + + # RHEL 8 + rh8: + default: true + os-base: rhel + image: rockylinux/rockylinux:8 + db: + - 10 + pg-repo: false + valgrind: false + epel: true + powertools: true + python: python3.12 + + # RHEL 9 + rh9: + default: true + os-base: rhel + image: rockylinux/rockylinux:9 + db: + - 14 + - 15 + - 16 + - 17 + - 18 + db-test: + - 14 + pg-repo-release: EL-9 + dnf-module: true + coverage-python: pip + epel: true + + # RHEL 10 + rh10: + default: true + os-base: rhel + image: rockylinux/rockylinux:10 + db: + - 14 + - 15 + - 16 + - 17 + - 18 + db-test: + - 18 + pg-repo-release: EL-10 + pg-repo-key: false + coverage-python: package + epel: true + + # Fedora 44 + f44: + os-base: rhel + image: fedora:44 + db: + - 14 + - 15 + - 16 + - 17 + - 18 + db-test: + - 15 + pg-repo-release: F-44 + coverage-c: true + coverage-python: package + + # Ubuntu 22.04 + u22: + default: true + os-base: debian + image: ubuntu:22.04 + db: + - 9.6 + - 10 + - 11 + - 12 + - 13 + - 14 + db-test: + - 9.6 + - 11 + coverage-c: true + ssh-rsa: true + + # Ubuntu 24.04 + u24: + default: true + os-base: debian + image: ubuntu:24.04 + db: + - 9.6 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 17 + - 18 + - 19 + db-test: + - 12 + - 13 + - 19 + coverage-c: true + coverage-python: package + ssh-rsa: true + # ********************************************************************************************************************************** # Container Cache Revisions # @@ -26,4 +195,5 @@ # rh10: 20260710A # rebuild rh10 on every architecture (independent of "all") # rh10-x86_64: 20260710A # rebuild rh10 on x86_64 only (independent of "all" and "rh10") # ********************************************************************************************************************************** -all: 20260710A +revision: + all: 20260710A diff --git a/test/lib/command/vm/build.py b/test/lib/command/vm/build.py index bb8221fbb..7ca0accc1 100644 --- a/test/lib/command/vm/build.py +++ b/test/lib/command/vm/build.py @@ -3,6 +3,9 @@ Generates a Dockerfile for each vm and builds it. Every vm has a base image with the packages the tests need and a test image that adds the test user, ssh, and the certificate authority, so a change to the test setup does not rebuild the packages. +What a vm installs and configures comes from its definition in test/container.yaml rather than from a test on its name here, so a +vm that needs a package or a repository the others do not is a change to the declaration rather than to the script generated here. + Base images are cached in a container registry keyed by a hash of the generated Dockerfile combined with the manual revision in test/container.yaml. The revision forces a rebuild when the Dockerfile is unchanged but the upstream packages have changed, e.g. a new PostgreSQL beta. @@ -15,14 +18,13 @@ import hashlib import os import re -import yaml - from common.error import check from common.exec import exec_one, exec_status from common.log import * from common.storage import file_read, file_write, path_create from common.user import group_id, group_name, user_id, user_name from common.vm import * +from common.yaml import yaml_load, yaml_map_dict # Separator between sections of the generated script. Every section is a continuation of a single RUN so the image has one layer. _SECTION = " && \\\n\n" @@ -176,7 +178,7 @@ def _entry_point_setup(os_base): #################################################################################################################################### -def _script_package(name, vm): +def _script_package(vm): """Install the packages the tests need.""" result = "" @@ -195,17 +197,14 @@ def _script_package(name, vm): result += _SECTION + "# Install packages\n" if vm.os_base == VM_OS_BASE_RHEL: - if name == VM_RH8: - result += ( - " dnf install -y dnf-plugins-core && \\\n" - + " dnf config-manager --set-enabled powertools && \\\n" - + " dnf -y install epel-release && \\\n" - + " crb enable && \\\n" - ) - elif name in (VM_RH9, VM_RH10): - result += ( - " dnf install -y dnf-plugins-core && \\\n" + " dnf -y install epel-release && \\\n" + " crb enable && \\\n" - ) + # Packages the tests need that rhel does not ship are in EPEL and CRB, which was called powertools before EL-9 + if vm.epel: + result += " dnf install -y dnf-plugins-core && \\\n" + + if vm.powertools: + result += " dnf config-manager --set-enabled powertools && \\\n" + + result += " dnf -y install epel-release && \\\n" + " crb enable && \\\n" result += ( " yum -y update && \\\n" @@ -214,22 +213,22 @@ def _script_package(name, vm): + " systemd-devel" ) - if name != VM_RH8: + if vm.valgrind: result += " valgrind" - # Coverage for the python tests. rh10 and later package a version new enough while rh9 packages one that predates the - # per line branch detail the report needs, so there it comes from pip below. - if name == VM_RH9: + # Coverage for the python tests, which comes from pip where the packaged version predates the per line branch detail the + # report needs. The pip install itself is below, since it needs the package installed here first. + if vm.coverage_python == "pip": result += " python3-pip" - elif name != VM_RH8: + elif vm.coverage_python is not None: result += " python3-coverage" - # The test harness needs a python3 that PyYAML is packaged for. On RH8 that is not the platform python, which meson - # installs and which claims the python3 alternative at a priority nothing else can outrank, so point it explicitly. - if name == VM_RH8: - result += " python3.12 python3.12-pyyaml && \\\n" + " alternatives --set python3 /usr/bin/python3.12" - else: + # The test harness needs a python3 that PyYAML is packaged for. Where that is not the platform python, which meson installs + # and which claims the python3 alternative at a priority nothing else can outrank, point it explicitly. + if vm.python is None: result += " python3-pyyaml" + else: + result += " %s %s-pyyaml && \\\n" % (vm.python, vm.python) + " alternatives --set python3 /usr/bin/%s" % vm.python elif vm.os_base == VM_OS_BASE_DEBIAN: result += ( " export DEBCONF_NONINTERACTIVE_SEEN=true DEBIAN_FRONTEND=noninteractive && \\\n" @@ -240,11 +239,11 @@ def _script_package(name, vm): + " libssh2-1-dev libcurl4-openssl-dev libsystemd-dev python3-yaml" ) - if name != VM_D12: + if vm.valgrind: result += " valgrind" # Coverage for the python tests, which are run on u24 and later - if name not in (VM_D12, VM_U22): + if vm.coverage_python is not None: result += " python3-coverage" result += " zstd libzstd-dev" @@ -257,16 +256,15 @@ def _script_package(name, vm): + " openssh-keygen zlib-dev libssh2-dev valgrind lz4 zstd zstd-dev py3-yaml" ) - # Coverage for python tests where the packaged version predates the per line branch detail the report needs, so it comes from - # pip rather than from the distribution - if name == VM_RH9: + # Coverage that comes from pip rather than from the distribution + if vm.coverage_python == "pip": result += _SECTION + "# Install python coverage\n" + " pip3 install --no-cache-dir 'coverage>=6.5'" return result #################################################################################################################################### -def _script_pg(name, vm, arch): +def _script_pg(vm, arch): """Install PostgreSQL.""" result = "" @@ -275,50 +273,51 @@ def _script_pg(name, vm, arch): result += _SECTION + "# Install PostgreSQL packages\n" if vm.os_base == VM_OS_BASE_RHEL: - # RHEL 10's rpm-sequoia OpenPGP backend rejects the SHA-1 binding signature in the PGDG GPG key, and the crypto-policy - # SHA1 subpolicy that could re-enable it was removed in EL-10. So rh10 skips the key import and installs the repo/packages - # with gpg checks disabled (acceptable for a throwaway test container). - if name != VM_RH10 and vm.pg_repo: - result += " rpm --import https://download.postgresql.org/pub/repos/yum/keys/RPM-GPG-KEY-PGDG && \\\n" - if not vm.pg_repo: # Enable the native PostgreSQL application stream instead of adding the PGDG repo result += " dnf -y module enable postgresql:%s && \\\n" % vm.db_list[-1] - elif name == VM_RH9: - result += ( - " rpm -ivh \\\n" - + " https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-%s/" % arch - + "pgdg-redhat-repo-latest.noarch.rpm && \\\n" - + " dnf -qy module disable postgresql && \\\n" - + " yum -y install libcurl-devel && \\\n" - ) - elif name == VM_RH10: - # Install the repo without a gpg check and disable gpgcheck on the PGDG repos (see the SHA-1 note above). RHEL 10 also - # dropped dnf modularity, so there is no postgresql module to disable. - result += ( - " dnf -y install --nogpgcheck \\\n" - + " https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-%s/" % arch - + "pgdg-redhat-repo-latest.noarch.rpm && \\\n" - + " sed -i 's/gpgcheck=1/gpgcheck=0/g' /etc/yum.repos.d/pgdg-redhat-all.repo && \\\n" - + " yum -y install libcurl-devel && \\\n" - ) - # Every rhel vm that installs from the PostgreSQL repo is one of the three above, so there is nothing to fall through to - elif name == VM_F44: # {uncoverable_branch} - result += ( - " rpm -ivh \\\n" - + " https://download.postgresql.org/pub/repos/yum/reporpms/F-44-%s/" % arch - + "pgdg-fedora-repo-latest.noarch.rpm && \\\n" - + " yum -y install libcurl-devel && \\\n" + else: + # The repo package is named for the distribution the release belongs to, e.g. EL-9 is redhat and F-44 is fedora + distro = "fedora" if vm.pg_repo_release.startswith("F-") else "redhat" + package = "https://download.postgresql.org/pub/repos/yum/reporpms/%s-%s/pgdg-%s-repo-latest.noarch.rpm" % ( + vm.pg_repo_release, + arch, + distro, ) + # The rpm-sequoia OpenPGP backend rejects the SHA-1 binding signature in the PGDG GPG key, and the crypto-policy SHA1 + # subpolicy that could re-enable it was removed in EL-10. A vm with that backend skips the key and installs the repo + # and its packages with gpg checks disabled, which is acceptable for a throwaway test container. + if vm.pg_repo_key: + result += ( + " rpm --import https://download.postgresql.org/pub/repos/yum/keys/RPM-GPG-KEY-PGDG && \\\n" + + " rpm -ivh \\\n" + + " %s && \\\n" % package + ) + else: + result += ( + " dnf -y install --nogpgcheck \\\n" + + " %s && \\\n" % package + + " sed -i 's/gpgcheck=1/gpgcheck=0/g' /etc/yum.repos.d/pgdg-%s-all.repo && \\\n" % distro + ) + + # Where the distribution still has dnf modularity its postgresql module shadows the PGDG packages, so disable it + if vm.dnf_module: + result += " dnf -qy module disable postgresql && \\\n" + + result += " yum -y install libcurl-devel && \\\n" + result += " yum -y install postgresql-devel" elif vm.os_base == VM_OS_BASE_DEBIAN: - # Install repo from apt.postgresql.org + # Install repo from apt.postgresql.org, adding the beta repo when an unreleased version is installed. The beta repo is not + # built for every architecture, so it is only added on the ones it is built for. if vm.pg_repo: + beta = vm.pg_beta is not None and arch in (VM_ARCH_AARCH64, VM_ARCH_X86_64) + result += ( " apt-get install -y --no-install-recommends postgresql-common && \\\n" + " /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y" - + (" -c 19" if name == VM_U24 and arch in (VM_ARCH_AARCH64, VM_ARCH_X86_64) else "") + + (" -c %s" % vm.pg_beta if beta else "") + " && \\\n" ) @@ -356,6 +355,7 @@ def _script_pg(name, vm, arch): result += " postgresql-%s" % db_version else: result += " postgresql%s" % db_version + # On other architectures (e.g. ppc64le, s390x) install a single PostgreSQL version for the smoke test via the postgresql # metapackage (its default from the configured repo). The full version matrix is skipped to keep the emulated build fast; one # version is enough to check the build and exercise checksums end-to-end on big-endian. @@ -381,7 +381,7 @@ def script_base(name, arch): """Generate the script for the base image, which holds everything installed from a package.""" vm = vm_get(name) - result = _script_package(name, vm) + result = _script_package(vm) result += ( _SECTION @@ -394,7 +394,7 @@ def script_base(name, arch): result += _SECTION + "# Fix root tty\n" + " sed -i 's/^mesg n/tty -s \\&\\& mesg n/g' /root/.profile" result += _SECTION + "# Suppress dpkg interactive output\n" + " rm /etc/apt/apt.conf.d/70debconf" - result += _script_pg(name, vm, arch) + result += _script_pg(vm, arch) if vm.os_base == VM_OS_BASE_DEBIAN: result += ( @@ -431,7 +431,7 @@ def script_test(name, path_repo): + " echo 'Banner /etc/issue.net' >> /etc/ssh/sshd_config" ) - if name in (VM_U22, VM_U24, VM_A321): + if vm.ssh_rsa: result += ( _SECTION + " echo '# Add PubkeyAcceptedAlgorithms (required for SFTP)' >> /etc/ssh/sshd_config && \\\n" @@ -504,29 +504,36 @@ def _revision_get(revision, name, arch): #################################################################################################################################### def revision_check(revision): - """Check the revisions read from container.yaml. + """Check the revisions declared in container.yaml, returning them keyed as they were declared. A revision is keyed by "all", a vm, or a vm qualified with an architecture, e.g. "u22-x86_64". Without this an invalid key would silently fall back to a less specific revision and the expected rebuild would never happen.""" + result = yaml_map_dict(revision, "the 'revision' section in %s" % VM_PATH_CONTAINER) + # The "all" revision is required so it can never be accidentally unset, which would change every image hash at once. It can # only be bumped. - check(isinstance(revision, dict) and VM_ALL in revision, "the 'all' revision is required in test/container.yaml") + check(VM_ALL in result, "the 'all' revision is required in %s" % VM_PATH_CONTAINER) - for key in sorted(revision): - check(revision[key] is not None, "revision '%s' in test/container.yaml must be set to a value" % key) + for key in sorted(result): + check(result[key] != "", "revision '%s' in %s must be set to a value" % (key, VM_PATH_CONTAINER)) if key == VM_ALL: continue key_vm, _, key_arch = key.partition("-") - check(vm_valid(key_vm) and key_vm != VM_NONE, "revision '%s' in test/container.yaml has invalid vm '%s'" % (key, key_vm)) + check( + vm_valid(key_vm) and key_vm != VM_NONE, + "revision '%s' in %s has invalid vm '%s'" % (key, VM_PATH_CONTAINER, key_vm), + ) check( key_arch == "" or key_arch in VM_ARCH_LIST, - "revision '%s' in test/container.yaml has invalid architecture '%s'" % (key, key_arch), + "revision '%s' in %s has invalid architecture '%s'" % (key, VM_PATH_CONTAINER, key_arch), ) + return result + #################################################################################################################################### def _container_write(path_repo, name, arch, title, image_parent, image, script, revision_map, cache, show_output): @@ -616,10 +623,14 @@ def cmd_vm_build(config): path_create(path_temp, mode=0o770) - with open(os.path.join(path_repo, "test/container.yaml"), "r") as file: - revision_map = yaml.safe_load(file) + # Revisions come from the repository being built rather than from the definitions, which are read from the repository this + # module is part of + path_container = os.path.join(path_repo, VM_PATH_CONTAINER) + container = yaml_map_dict(yaml_load(file_read(path_container), VM_PATH_CONTAINER), VM_PATH_CONTAINER) - revision_check(revision_map) + check("revision" in container, "the 'revision' section is required in %s" % VM_PATH_CONTAINER) + + revision_map = revision_check(container["revision"]) for name in VM_LIST if config.vm == VM_ALL else [config.vm]: # Base image diff --git a/test/lib/common/vm.py b/test/lib/common/vm.py index fc61e6bd9..c166f1be4 100644 --- a/test/lib/common/vm.py +++ b/test/lib/common/vm.py @@ -3,19 +3,30 @@ The vms tests can run on, the PostgreSQL versions each one provides, and what the container build needs to know about them. A vm name is what --vm accepts, e.g. u24 for Ubuntu 24.04. -The default list is checked when this module is imported: every supported PostgreSQL version must be tested on exactly one default -vm and at least one default vm must collect C coverage. Without the check a new version could be added and silently never tested.""" +The definitions are declared in test/container.yaml and are read when this module is imported, so a definition that does not parse +fails before any tool that reads it runs. The declaration is checked as well: every supported PostgreSQL version must be tested on +exactly one default vm and at least one default vm must collect C coverage. Without the check a new version could be added and +silently never tested. + +The versions the project supports are not declared here either. They come from build/postgres.yaml, which is what the version +interfaces are generated from, so a version is supported and tested because one declaration says so.""" #################################################################################################################################### +import os import platform from common.error import ToolError, check +from common.storage import file_read +from common.yaml import yaml_bool, yaml_load, yaml_map_dict +from postgres.parse import bld_pg_version_list # Os base a vm is built on, which determines its package manager and where PostgreSQL is installed VM_OS_BASE_ALPINE = "alpine" VM_OS_BASE_DEBIAN = "debian" VM_OS_BASE_RHEL = "rhel" +VM_OS_BASE_LIST = (VM_OS_BASE_ALPINE, VM_OS_BASE_DEBIAN, VM_OS_BASE_RHEL) + # Architectures a container can be built for VM_ARCH_AARCH64 = "aarch64" VM_ARCH_I386 = "i386" @@ -29,76 +40,155 @@ VM_ARCH_LIST = (VM_ARCH_AARCH64, VM_ARCH_I386, VM_ARCH_PPC64LE, VM_ARCH_S390X, V VM_ALL = "all" VM_NONE = "none" -# Vms -VM_A321 = "a321" -VM_A324 = "a324" -VM_D12 = "d12" -VM_F44 = "f44" -VM_RH8 = "rh8" -VM_RH9 = "rh9" -VM_RH10 = "rh10" -VM_U22 = "u22" -VM_U24 = "u24" +# Where the python coverage package comes from, since not every vm packages a version new enough to report branch detail +_VM_COVERAGE_PYTHON_LIST = ("package", "pip") -# Default vms, i.e. the ones --vm=all selects -VM_LIST = (VM_D12, VM_RH8, VM_RH9, VM_RH10, VM_U22, VM_U24, VM_A321, VM_A324) +# Declaration the definitions are read from, named as the tools report it rather than as it is opened +VM_PATH_CONTAINER = "test/container.yaml" + +# Repository the declarations are read from. This module is part of the repository, so its own path locates it, and a tool imports +# it before it has a repository path of its own to give. +_PATH_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +# Supported PostgreSQL versions, oldest first +_PG_LIST = bld_pg_version_list(_PATH_REPO) # PostgreSQL versions the project supports. Each one must be tested on a default vm, which vm_check() enforces. -PG_VERSION_LIST = ("9.6", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19") +PG_VERSION_LIST = tuple(pg.version for pg in _PG_LIST) + +# Versions that have not been released yet, which are only in the beta repository +_PG_BETA_LIST = tuple(pg.version for pg in _PG_LIST if not pg.release) + +# Keys a vm with no container may declare, since every other key says how a container is built +_VM_KEY_HOST_LIST = ("coverage-c", "default") + +# Keys a vm may declare +_VM_KEY_LIST = ( + "coverage-c", + "coverage-python", + "db", + "db-test", + "default", + "dnf-module", + "epel", + "image", + "os-base", + "pg-repo", + "pg-repo-key", + "pg-repo-release", + "powertools", + "python", + "ssh-rsa", + "valgrind", +) #################################################################################################################################### class Vm: """A vm definition.""" - def __init__(self, os_base, db_list, image=None, db_test_list=None, pg_repo=True, coverage_c=False): - self.os_base = os_base - self.image = image # Image the container is built from, None when the vm has no container - self.pg_repo = pg_repo # Install PostgreSQL from the PGDG repo rather than from the distribution? - self.coverage_c = coverage_c # Is C coverage collected on this vm? - self.db_list = db_list # PostgreSQL versions installed - self.db_test_list = db_list if db_test_list is None else db_test_list # Versions integration tests run against + def __init__(self, name): + self.name = name + self.default = False # Is this a default vm, i.e. one of the vms --vm=all selects? + self.os_base = None # Os the container is built on, which determines its package manager and where PostgreSQL is installed + self.image = None # Image the container is built from, None when the vm has no container + self.db_list = [] # PostgreSQL versions installed + self.db_test_list = [] # Versions integration tests run against, which is every version installed unless declared + self.pg_beta = None # Unreleased version installed, which is only in the beta repository + self.pg_repo = True # Install PostgreSQL from the PGDG repo rather than from the distribution? + self.pg_repo_key = True # Import the PGDG signing key, which not every rpm backend accepts? + self.pg_repo_release = None # Release the PGDG repo package is built for, e.g. EL-9 + self.dnf_module = False # Disable the distribution's postgresql dnf module so the PGDG packages are used? + self.coverage_c = False # Is C coverage collected on this vm? + self.coverage_python = None # Where the python coverage package comes from, None when python coverage is not collected + self.valgrind = True # Install valgrind? + self.epel = False # Install EPEL and enable CRB, which is where the packages rhel does not ship itself are? + self.powertools = False # Enable powertools, which is what CRB was called before EL-9? + self.python = None # Python to install and make the default, when the platform python is not the one to use + self.ssh_rsa = False # Add back the ssh-rsa algorithms, which SFTP needs and a newer sshd rejects by default? #################################################################################################################################### -# fmt: off -_VM = { - # No container, i.e. run directly on the host - VM_NONE: Vm(VM_OS_BASE_DEBIAN, ["10"], coverage_c=True), +def _vm_parse(name, definition): + """Parse a single vm definition.""" - # Alpine 3.21 - VM_A321: Vm(VM_OS_BASE_ALPINE, ["15", "16", "17"], image="alpine:3.21", db_test_list=["16"], pg_repo=False), + result = Vm(name) + key_map = yaml_map_dict(definition, "vm '%s'" % name) - # Alpine 3.24 - VM_A324: Vm(VM_OS_BASE_ALPINE, ["16", "17", "18"], image="alpine:3.24", db_test_list=["17"], pg_repo=False), + for key in key_map: + check(key in _VM_KEY_LIST, "unknown key '%s' in vm '%s'" % (key, name)) - # Debian 12 - VM_D12: Vm(VM_OS_BASE_DEBIAN, ["15"], image="debian:12", pg_repo=False), + def flag(key, default="false"): + """A boolean key, which defaults to what a vm that does not declare it gets.""" - # RHEL 8 - VM_RH8: Vm(VM_OS_BASE_RHEL, ["10"], image="rockylinux/rockylinux:8", pg_repo=False), + return yaml_bool(key_map.get(key, default), "vm '%s' key '%s'" % (name, key)) - # RHEL 9 - VM_RH9: Vm(VM_OS_BASE_RHEL, ["14", "15", "16", "17", "18"], image="rockylinux/rockylinux:9", db_test_list=["14"]), + def version_list(key, default): + """A list of PostgreSQL versions, which must be written as a list even when there is only one of them.""" - # RHEL 10 - VM_RH10: Vm(VM_OS_BASE_RHEL, ["14", "15", "16", "17", "18"], image="rockylinux/rockylinux:10", db_test_list=["18"]), + value = key_map.get(key, default) - # Fedora 44 - VM_F44: Vm( - VM_OS_BASE_RHEL, ["14", "15", "16", "17", "18"], image="fedora:44", db_test_list=["15"], coverage_c=True), + check(isinstance(value, list), "vm '%s' key '%s' must be a list" % (name, key)) - # Ubuntu 22.04 - VM_U22: Vm( - VM_OS_BASE_DEBIAN, ["9.6", "10", "11", "12", "13", "14"], image="ubuntu:22.04", db_test_list=["9.6", "11"], - coverage_c=True), + return list(value) - # Ubuntu 24.04 - VM_U24: Vm( - VM_OS_BASE_DEBIAN, ["9.6", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19"], image="ubuntu:24.04", - db_test_list=["12", "13", "19"], coverage_c=True), -} -# fmt: on + result.default = flag("default") + result.os_base = key_map.get("os-base") + result.image = key_map.get("image") + result.db_list = version_list("db", []) + result.db_test_list = version_list("db-test", result.db_list) + result.pg_repo = flag("pg-repo", "true") + result.pg_repo_key = flag("pg-repo-key", "true") + result.pg_repo_release = key_map.get("pg-repo-release") + result.dnf_module = flag("dnf-module") + result.coverage_c = flag("coverage-c") + result.coverage_python = key_map.get("coverage-python") + result.valgrind = flag("valgrind", "true") + result.epel = flag("epel") + result.powertools = flag("powertools") + result.python = key_map.get("python") + result.ssh_rsa = flag("ssh-rsa") + + # A vm with no image has no container to build, so it declares nothing about how one would be built. That includes the os it + # runs on, which is whatever the host is rather than anything this can know. + if result.image is None: + for key in key_map: + check(key in _VM_KEY_HOST_LIST, "vm '%s' has no container so it may not declare '%s'" % (name, key)) + else: + check( + result.os_base in VM_OS_BASE_LIST, + "vm '%s' must declare one of these os bases: %s" % (name, ", ".join(VM_OS_BASE_LIST)), + ) + + check( + result.coverage_python in (None,) + _VM_COVERAGE_PYTHON_LIST, + "invalid python coverage '%s' in vm '%s'" % (result.coverage_python, name), + ) + + for db in result.db_list: + check(db in PG_VERSION_LIST, "PostgreSQL %s in vm '%s' is not a supported version" % (db, name)) + + for db in result.db_test_list: + check(db in result.db_list, "PostgreSQL %s tested on vm '%s' is not installed there" % (db, name)) + + # An unreleased version comes from the beta repository, which the container build needs to add + result.pg_beta = next((db for db in result.db_list if db in _PG_BETA_LIST), None) + + return result + + +#################################################################################################################################### +def vm_parse(content): + """Parse the vm definitions, keyed by vm name.""" + + section = yaml_map_dict(yaml_load(content, VM_PATH_CONTAINER), VM_PATH_CONTAINER) + + check("vm" in section, "the 'vm' section is required in %s" % VM_PATH_CONTAINER) + + return { + name: _vm_parse(name, definition) + for name, definition in yaml_map_dict(section["vm"], "the 'vm' section in %s" % VM_PATH_CONTAINER).items() + } #################################################################################################################################### @@ -118,18 +208,27 @@ def vm_get(vm): return _VM[vm] +#################################################################################################################################### +def vm_default_list(vm_map): + """The default vms in a definition map, i.e. the ones --vm=all selects.""" + + return tuple(name for name, vm in vm_map.items() if vm.default) + + #################################################################################################################################### def vm_check(vm_map): """Check that C coverage and every supported PostgreSQL version are tested on a default vm. - Called below on the definitions in this module, so a version that no vm tests is an error rather than a silent gap.""" + Called below on the definitions that were read, so a version that no vm tests is an error rather than a silent gap.""" - check(any(vm_map[vm].coverage_c for vm in VM_LIST), "C coverage is not configured to run on a default vm") + vm_list = vm_default_list(vm_map) + + check(any(vm_map[vm].coverage_c for vm in vm_list), "C coverage is not configured to run on a default vm") for version in PG_VERSION_LIST: vm_found = None - for vm in VM_LIST: + for vm in vm_list: if version in vm_map[vm].db_test_list: check(vm_found is None, "PostgreSQL %s is already configured to run on default vm %s" % (version, vm_found)) @@ -150,4 +249,10 @@ def host_arch(): #################################################################################################################################### +# Definitions, read and checked on import so a tool that reads them never has to +_VM = vm_parse(file_read(os.path.join(_PATH_REPO, VM_PATH_CONTAINER))) + vm_check(_VM) + +# Default vms, i.e. the ones --vm=all selects +VM_LIST = vm_default_list(_VM) diff --git a/test/src/module/build/common/yaml_test.py b/test/src/module/build/common/yaml_test.py index a60bd3b17..a5303fc1f 100644 --- a/test/src/module/build/common/yaml_test.py +++ b/test/src/module/build/common/yaml_test.py @@ -59,6 +59,23 @@ def test_yaml_bool(): assert_equal(str(error.exception), "invalid boolean 'yes' for key") +#################################################################################################################################### +def test_yaml_map_dict(): + """A mapping is a dict where a key is looked up by name, and a repeated key is reported rather than kept.""" + + assert_equal(yaml_map_dict(yaml_load("b: 1\na: 2\n", "test.yaml"), "key"), {"b": "1", "a": "2"}) + + with assert_raises(ToolError) as error: + yaml_map_dict(yaml_load("- 1\n", "test.yaml"), "key") + + assert_equal(str(error.exception), "key must be a map") + + with assert_raises(ToolError) as error: + yaml_map_dict(yaml_load("b: 1\na: 2\nb: 3\n", "test.yaml"), "key") + + assert_equal(str(error.exception), "key has duplicate key 'b'") + + #################################################################################################################################### def test_yaml_map_empty(): """A key that is a name in a list has an empty map under it and nothing else.""" diff --git a/test/src/module/test/command/test/job_test.py b/test/src/module/test/command/test/job_test.py index 4ddc44083..6a2720c84 100644 --- a/test/src/module/test/command/test/job_test.py +++ b/test/src/module/test/command/test/job_test.py @@ -226,7 +226,7 @@ def test_job_command_option(): assert_in(" --no-coverage ", job.exec.command) # Nor on a vm that cannot collect it - job = _job(Config(path), _run(vm=VM_D12)) + job = _job(Config(path), _run(vm="d12")) _capture(job, lambda job: job.begin()) @@ -271,7 +271,7 @@ def test_job_container(): with tempfile.TemporaryDirectory() as path: config = Config(path, vm_arch="x86_64") - job = _job(config, _run(vm=VM_U24), vm_idx=1, vm_max=2, test_idx=3, test_max=12) + job = _job(config, _run(vm="u24"), vm_idx=1, vm_max=2, test_idx=3, test_max=12) started, command_list, output = _capture(job, lambda job: job.begin()) @@ -296,7 +296,7 @@ def test_job_container(): assert_equal(job.description, "P2-T04/12 - vm=u24, module=common/error") # An integration test starts its own containers and has nothing built for it, so neither is done here - job = _job(config, _run(name="integration/all", type=TEST_TYPE_INTEGRATION, vm=VM_U24, test_list=[1])) + job = _job(config, _run(name="integration/all", type=TEST_TYPE_INTEGRATION, vm="u24", test_list=[1])) started, command_list, output = _capture(job, lambda job: job.begin()) @@ -441,7 +441,7 @@ def test_job_end_fail(): with tempfile.TemporaryDirectory() as path: config = Config(path) - job = _job(config, _run(vm=VM_U24)) + job = _job(config, _run(vm="u24")) _capture(job, lambda job: job.begin()) (done, fail), command_list, output = _capture(job, lambda job: job.end(), status=25, output="it broke\n") @@ -475,7 +475,7 @@ def test_job_profile(): with tempfile.TemporaryDirectory() as path: config = Config(path, profile=True) - job = _job(config, _run(vm=VM_U24)) + job = _job(config, _run(vm="u24")) _capture(job, lambda job: job.begin()) diff --git a/test/src/module/test/command/test/list_test.py b/test/src/module/test/command/test/list_test.py index 1813d14c7..41314ff95 100644 --- a/test/src/module/test/command/test/list_test.py +++ b/test/src/module/test/command/test/list_test.py @@ -54,7 +54,7 @@ MODULE_LIST = [ _module("common/error/error", coverage_list=["common/error"]), _module("common/exec"), _module("common/socket", container_required=True), - _module("doc/build", vm_list=[VM_U24]), + _module("doc/build", vm_list=["u24"]), _module("test/common/log", type=TEST_TYPE_TOOL, lang=TEST_LANG_PYTHON, coverage_list=["test/common/log"]), _module("integration/all", type=TEST_TYPE_INTEGRATION, total=3, pg_required=True), _module("performance/type", type=TEST_TYPE_PERFORMANCE, total=2), @@ -72,17 +72,17 @@ def test_list_all(): ) # A vm with a container runs everything, and the integration test runs once per sub-test and PostgreSQL version - test_list = test_list_get(MODULE_LIST, _Config(vm=VM_U24)) + test_list = test_list_get(MODULE_LIST, _Config(vm="u24")) assert_equal( _name_list(test_list), ["common/error/error", "common/exec", "common/socket", "doc/build", "test/common/log"] - + ["integration/all"] * 3 * len(vm_get(VM_U24).db_test_list) + + ["integration/all"] * 3 * len(vm_get("u24").db_test_list) + ["performance/type"], ) # A test that only runs on one vm is left out of the others - assert_true("doc/build" not in _name_list(test_list_get(MODULE_LIST, _Config(vm=VM_D12)))) + assert_true("doc/build" not in _name_list(test_list_get(MODULE_LIST, _Config(vm="d12")))) # Every default vm runs its own copy of the list assert_equal(len(set(run.vm for run in test_list_get(MODULE_LIST, _Config(vm=VM_ALL)))), len(VM_LIST)) @@ -122,12 +122,12 @@ def test_list_filter(): """The filters leave out the kinds of test they name.""" # Integration tests run the binary rather than a unit test, so they are what --c-only leaves out - assert_true("integration/all" not in _name_list(test_list_get(MODULE_LIST, _Config(vm=VM_U24, c_only=True)))) + assert_true("integration/all" not in _name_list(test_list_get(MODULE_LIST, _Config(vm="u24", c_only=True)))) # Only tests that must have a container, which includes every integration test assert_equal( - _name_list(test_list_get(MODULE_LIST, _Config(vm=VM_U24, container_only=True))), - ["common/socket"] + ["integration/all"] * 3 * len(vm_get(VM_U24).db_test_list), + _name_list(test_list_get(MODULE_LIST, _Config(vm="u24", container_only=True))), + ["common/socket"] + ["integration/all"] * 3 * len(vm_get("u24").db_test_list), ) # Only tests that provide coverage, which is what the documentation summary is built from @@ -141,22 +141,22 @@ def test_list_filter(): def test_list_pg_version(): """An integration test runs against the PostgreSQL versions the vm tests, most recent first.""" - version_list = vm_get(VM_U24).db_test_list + version_list = vm_get("u24").db_test_list assert_equal( - [run.pg_version for run in test_list_get(MODULE_LIST, _Config(vm=VM_U24, module=["integration"]))], + [run.pg_version for run in test_list_get(MODULE_LIST, _Config(vm="u24", module=["integration"]))], [version for version in reversed(version_list) for _ in range(3)], ) # A single version can be selected, which is how a version specific problem is chased down assert_equal( - [run.pg_version for run in test_list_get(MODULE_LIST, _Config(vm=VM_U24, module=["integration"], pg_version="12"))], + [run.pg_version for run in test_list_get(MODULE_LIST, _Config(vm="u24", module=["integration"], pg_version="12"))], ["12"] * 3, ) # A version that the vm does not test selects nothing assert_equal( - test_list_get(MODULE_LIST, _Config(vm=VM_U24, module=["integration"], pg_version="8.0")), + test_list_get(MODULE_LIST, _Config(vm="u24", module=["integration"], pg_version="8.0")), [], ) @@ -165,7 +165,7 @@ def test_list_pg_version(): def test_list_run(): """Each sub-test of an integration test is a run of its own, since each one needs its own containers.""" - test_list = test_list_get(MODULE_LIST, _Config(vm=VM_U24, module=["integration"], pg_version="12")) + test_list = test_list_get(MODULE_LIST, _Config(vm="u24", module=["integration"], pg_version="12")) assert_equal([run.test_list for run in test_list], [[1], [2], [3]]) assert_equal(test_list[0].coverage_name, "integration-all") @@ -173,7 +173,7 @@ def test_list_run(): assert_false(test_list[0].performance) # A run can be selected, which is the only way to get one container out of an integration test - config = _Config(vm=VM_U24, module=["integration/all"], pg_version="12", test=[2]) + config = _Config(vm="u24", module=["integration/all"], pg_version="12", test=[2]) assert_equal([run.test_list for run in test_list_get(MODULE_LIST, config)], [[2]]) diff --git a/test/src/module/test/command/test/test_test.py b/test/src/module/test/command/test/test_test.py index 404e02572..befe1c2ec 100644 --- a/test/src/module/test/command/test/test_test.py +++ b/test/src/module/test/command/test/test_test.py @@ -348,7 +348,7 @@ def test_test_build(): assert_equal(started, ["common/error", "common/exec"]) # An integration test runs the binary so it is built. The build is already set up so only the binary is built. - status, command_list, started, output = _cmd_test(Config(path_repo, path_test, vm=VM_U24, module=["integration"])) + status, command_list, started, output = _cmd_test(Config(path_repo, path_test, vm="u24", module=["integration"])) assert_in("ninja -C %s/build/u24 src/pgbackrest 2>&1" % path_test, " ".join(command_list)) @@ -379,7 +379,7 @@ def test_test_container(): path_repo = _repo_create(path) path_test = os.path.join(path, "test") - status, command_list, started, output = _cmd_test(Config(path_repo, path_test, vm=VM_U24, module=["common"])) + status, command_list, started, output = _cmd_test(Config(path_repo, path_test, vm="u24", module=["common"])) assert_equal(_cmd_test.container_remove.call_args[0][0], "test-([0-9]+|build)") assert_in("cleanup old data and containers", output) @@ -478,7 +478,7 @@ def test_test_coverage(): assert_equal(_cmd_test.coverage.call_count, 0) - _cmd_test(Config(path_repo, path_test, vm=VM_D12, module=["common"])) + _cmd_test(Config(path_repo, path_test, vm="d12", module=["common"])) assert_equal(_cmd_test.coverage.call_count, 0) diff --git a/test/src/module/test/command/vm/build_test.py b/test/src/module/test/command/vm/build_test.py index 8b6621c5a..12d9df45a 100644 --- a/test/src/module/test/command/vm/build_test.py +++ b/test/src/module/test/command/vm/build_test.py @@ -21,9 +21,13 @@ from common.log import * from common.storage import file_read, file_write from common.user import group_id, group_name, user_id, user_name from common.vm import * +from common.yaml import yaml_load -# Revisions as test/container.yaml declares them -REVISION = {"all": "1", VM_U24: "2", "%s-%s" % (VM_U24, VM_ARCH_X86_64): "3"} +# Revisions the declaration written here holds, as they are read +REVISION = {"all": "1", "u24": "2", "u24-x86_64": "3"} + +# Revision section of test/container.yaml, which is all of it that the build reads +REVISION_YAML = "revision:\n all: '1'\n u24: '2'\n u24-x86_64: '3'\n" # The base script for a vm that installs from the PostgreSQL repo and collects coverage, i.e. one that exercises every option the # debian build has @@ -142,10 +146,12 @@ def _collapse(script): #################################################################################################################################### -def _repo_write(path): - """Write the part of the repository the vm build reads.""" +def _repo_write(path, container=REVISION_YAML): + """Write the part of the repository the vm build reads. - file_write(os.path.join(path, "test/container.yaml"), "all: '1'\n%s: '2'\n%s-%s: '3'\n" % (VM_U24, VM_U24, VM_ARCH_X86_64)) + The vm definitions are read from the repository this test is part of, so only the revisions are written here.""" + + file_write(os.path.join(path, "test/container.yaml"), container) file_write( os.path.join(path, "test/certificate/pgbackrest-test-ca.crt"), "-----BEGIN CERTIFICATE-----\nQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9w\n-----END CERTIFICATE-----\n", @@ -210,8 +216,8 @@ def test_container_script(): with tempfile.TemporaryDirectory() as path: _repo_write(path) - assert_equal(script_base(VM_U24, VM_ARCH_X86_64), SCRIPT_BASE_U24) - assert_equal(_collapse(script_test(VM_U24, path)), _expect(SCRIPT_TEST_U24)) + assert_equal(script_base("u24", VM_ARCH_X86_64), SCRIPT_BASE_U24) + assert_equal(_collapse(script_test("u24", path)), _expect(SCRIPT_TEST_U24)) # The script is wrapped in a Dockerfile that runs it as a single layer assert_equal( @@ -228,7 +234,7 @@ def test_container_script_vm(): """Each vm installs what its os base and PostgreSQL repository need.""" # RHEL 8 has no valgrind or coverage package and needs a python that PyYAML is packaged for - script = script_base(VM_RH8, VM_ARCH_X86_64) + script = script_base("rh8", VM_ARCH_X86_64) assert_true("--set-enabled powertools" in script) assert_true("valgrind" not in script) @@ -242,49 +248,57 @@ def test_container_script_vm(): # Every RHEL vm tunes dnf to fail over from a slow mirror assert_true("echo fastestmirror=True; } >> /etc/dnf/dnf.conf" in script) - # RHEL 9 packages a coverage too old to report the branch detail the report needs, so it comes from pip - script = script_base(VM_RH9, VM_ARCH_X86_64) + # RHEL 9 packages a coverage too old to report the branch detail the report needs, so it comes from pip. It still has dnf + # modularity, so the module that would shadow the PostgreSQL repo packages is disabled. + script = script_base("rh9", VM_ARCH_X86_64) assert_true("python3-pip" in script) assert_true("pip3 install --no-cache-dir 'coverage>=6.5'" in script) assert_true("RPM-GPG-KEY-PGDG" in script) - assert_true("reporpms/EL-9-%s/" % VM_ARCH_X86_64 in script) + assert_true("reporpms/EL-9-%s/pgdg-redhat-repo-latest" % VM_ARCH_X86_64 in script) + assert_true("dnf -qy module disable postgresql" in script) assert_true("ENV PATH=/usr/pgsql-18/bin:$PATH" in script) # RHEL 10 cannot check the PostgreSQL repo signature so it skips the key and disables the check - script = script_base(VM_RH10, VM_ARCH_X86_64) + script = script_base("rh10", VM_ARCH_X86_64) assert_true("python3-coverage" in script) assert_true("RPM-GPG-KEY-PGDG" not in script) assert_true("s/gpgcheck=1/gpgcheck=0/g" in script) + assert_true("dnf -qy module disable postgresql" not in script) - # Fedora installs from the Fedora build of the PostgreSQL repo - assert_true("reporpms/F-44-%s/" % VM_ARCH_X86_64 in script_base(VM_F44, VM_ARCH_X86_64)) + # Fedora installs from the Fedora build of the PostgreSQL repo, which is named for the distribution rather than for RHEL + script = script_base("f44", VM_ARCH_X86_64) + + assert_true("reporpms/F-44-%s/pgdg-fedora-repo-latest" % VM_ARCH_X86_64 in script) + + # Fedora ships the packages that RHEL puts in EPEL and CRB + assert_true("epel-release" not in script) # Debian 12 installs PostgreSQL from the distribution and runs neither valgrind nor coverage - script = script_base(VM_D12, VM_ARCH_X86_64) + script = script_base("d12", VM_ARCH_X86_64) assert_true("valgrind" not in script) assert_true("python3-coverage" not in script) assert_true("apt.postgresql.org.sh" not in script) # Ubuntu 22.04 is the last release the python tests do not run on - assert_true("python3-coverage" not in script_base(VM_U22, VM_ARCH_X86_64)) + assert_true("python3-coverage" not in script_base("u22", VM_ARCH_X86_64)) # Alpine installs everything from one package manager and needs no PostgreSQL repo - script = script_base(VM_A321, VM_ARCH_X86_64) + script = script_base("a321", VM_ARCH_X86_64) assert_true("apk add --no-cache postgresql15 postgresql16 postgresql17" in script) assert_true("Install PostgreSQL packages" not in script) # An architecture the PostgreSQL repo does not build for installs one version for the smoke test - script = script_base(VM_U24, VM_ARCH_PPC64LE) + script = script_base("u24", VM_ARCH_PPC64LE) assert_true("apt.postgresql.org.sh -y &&" in script) assert_true("Install a single PostgreSQL version for the smoke test" in script) # There is nothing to install for alpine on that architecture, since it has no smoke test - assert_true("Install PostgreSQL" not in script_base(VM_A324, VM_ARCH_PPC64LE)) + assert_true("Install PostgreSQL" not in script_base("a324", VM_ARCH_PPC64LE)) #################################################################################################################################### @@ -298,7 +312,7 @@ def test_container_script_test_vm(): # architecture is the host architecture rather than the one being built for, so it is set here to get the same script # whichever machine the tests are run on. with patch("command.vm.build.host_arch", return_value=VM_ARCH_X86_64): - script = script_test(VM_RH9, path) + script = script_test("rh9", path) assert_true("/etc/pki/ca-trust/source/anchors/pgbackrest-test-ca.crt" in script) assert_true("update-ca-trust extract" in script) @@ -310,55 +324,68 @@ def test_container_script_test_vm(): assert_true("rm /etc/profile.d/lang.sh" not in script) with patch("command.vm.build.host_arch", return_value=VM_ARCH_AARCH64): - assert_true("rm /etc/profile.d/lang.sh" in script_test(VM_RH9, path)) + assert_true("rm /etc/profile.d/lang.sh" in script_test("rh9", path)) # Alpine creates the group differently, needs a password for the test user, and starts ssh without a service - script = script_test(VM_A321, path) + script = script_test("a321", path) assert_true("getent group %s >/dev/null || addgroup" % group_name() in script) assert_true("passwd -d '%s' %s" % (user_name(), user_name()) in script) assert_true("ENTRYPOINT /usr/sbin/sshd -D" in script) # Only the releases with an ssh that rejects the test key by default need the algorithms added back - assert_true("PubkeyAcceptedAlgorithms" not in script_test(VM_D12, path)) + assert_true("PubkeyAcceptedAlgorithms" not in script_test("d12", path)) + + +#################################################################################################################################### +def _revision(section): + """The revisions in a declaration, written as they are in test/container.yaml.""" + + return revision_check(yaml_load(section, "test/container.yaml")) #################################################################################################################################### def test_container_revision(): """A revision must be readable and name a vm that exists, else a rebuild would silently never happen.""" - assert_is_none(revision_check(REVISION)) + assert_equal(_revision("all: '1'\nu24: '2'\nu24-x86_64: '3'\n"), REVISION) with assert_raises(ToolError) as error: - revision_check(["1"]) + _revision("- '1'\n") + + assert_equal(str(error.exception), "the 'revision' section in test/container.yaml must be a map") + + # A repeated key is kept by the loader, so a revision that was written twice is reported rather than silently taking the last + with assert_raises(ToolError) as error: + _revision("all: '1'\nall: '2'\n") + + assert_equal(str(error.exception), "the 'revision' section in test/container.yaml has duplicate key 'all'") + + with assert_raises(ToolError) as error: + _revision("u24: '1'\n") assert_equal(str(error.exception), "the 'all' revision is required in test/container.yaml") with assert_raises(ToolError) as error: - revision_check({VM_U24: "1"}) + _revision("all: '1'\nu24:\n") - assert_equal(str(error.exception), "the 'all' revision is required in test/container.yaml") + assert_equal(str(error.exception), "revision 'u24' in test/container.yaml must be set to a value") with assert_raises(ToolError) as error: - revision_check({VM_ALL: "1", VM_U24: None}) - - assert_equal(str(error.exception), "revision '%s' in test/container.yaml must be set to a value" % VM_U24) - - with assert_raises(ToolError) as error: - revision_check({VM_ALL: "1", "bogus": "1"}) + _revision("all: '1'\nbogus: '1'\n") assert_equal(str(error.exception), "revision 'bogus' in test/container.yaml has invalid vm 'bogus'") # There is no container for none so a revision for it could never be used with assert_raises(ToolError) as error: - revision_check({VM_ALL: "1", "none": "1"}) + _revision("all: '1'\nnone: '1'\n") assert_equal(str(error.exception), "revision 'none' in test/container.yaml has invalid vm 'none'") with assert_raises(ToolError) as error: - revision_check({VM_ALL: "1", "%s-bogus" % VM_U24: "1"}) + _revision("all: '1'\nu24-bogus: '1'\n") - assert_equal(str(error.exception), "revision '%s-bogus' in test/container.yaml has invalid architecture 'bogus'" % VM_U24) + assert_equal(str(error.exception), "revision 'u24-bogus' in test/container.yaml has invalid architecture 'bogus'") #################################################################################################################################### @@ -368,8 +395,8 @@ def test_vm_build(): with tempfile.TemporaryDirectory() as path: _repo_write(path) - image_base = "%s:%s-base-%s" % (container_repo(), VM_U24, VM_ARCH_X86_64) - image_test = "%s:%s-test-%s" % (container_repo(), VM_U24, VM_ARCH_X86_64) + image_base = "%s:%s-base-%s" % (container_repo(), "u24", VM_ARCH_X86_64) + image_test = "%s:%s-test-%s" % (container_repo(), "u24", VM_ARCH_X86_64) # The base image is in the cache so only the test image is built. The revision for the vm and architecture is the most # specific one so it is the one in the cache tag. @@ -377,7 +404,7 @@ def test_vm_build(): with patch("command.vm.build.exec_one", docker.one): with patch("command.vm.build.exec_status", docker.status_get): - cmd_vm_build(_Config(path, VM_U24)) + cmd_vm_build(_Config(path, "u24")) assert_equal(len(docker.match("^docker pull .*-base-.*-3-")), 1) assert_equal(docker.match("^docker tag"), ["docker tag %s-3-%s %s" % (image_base, _hash(docker), image_base)]) @@ -385,7 +412,7 @@ def test_vm_build(): assert_true(docker.match("^docker build")[0].endswith("-t %s %s" % (image_test, os.path.join(path, "test")))) # The Dockerfile is written where the build can find it and is the script with the trailing separator removed - assert_true(file_read(os.path.join(path, "test/result/docker/%s-test-%s" % (VM_U24, VM_ARCH_X86_64))).endswith("bash\n")) + assert_true(file_read(os.path.join(path, "test/result/docker/%s-test-%s" % ("u24", VM_ARCH_X86_64))).endswith("bash\n")) # Nothing is in the cache so the base image is built and pushed. A fork pushes to its own cache since it cannot write to # the pgbackrest one. @@ -394,7 +421,7 @@ def test_vm_build(): with patch("command.vm.build.exec_one", docker.one): with patch("command.vm.build.exec_status", docker.status_get): with patch.dict(os.environ, {"GITHUB_REPOSITORY_OWNER": "Someone"}): - cmd_vm_build(_Config(path, VM_U24, log_level=DETAIL)) + cmd_vm_build(_Config(path, "u24", log_level=DETAIL)) assert_equal(len(docker.match("^docker pull %s:" % container_repo())), 1) assert_equal(len(docker.match("^docker pull %s:" % container_repo("someone"))), 1) @@ -406,7 +433,7 @@ def test_vm_build(): with patch("command.vm.build.exec_one", docker.one): with patch("command.vm.build.exec_status", docker.status_get): - cmd_vm_build(_Config(path, VM_D12)) + cmd_vm_build(_Config(path, "d12")) assert_equal(len(docker.match("^docker push %s:" % container_repo())), 1) @@ -416,7 +443,7 @@ def test_vm_build(): with patch("command.vm.build.exec_one", docker.one): with patch("command.vm.build.exec_status", docker.status_get): - cmd_vm_build(_Config(path, VM_U24, cache=False)) + cmd_vm_build(_Config(path, "u24", cache=False)) assert_equal(len(docker.match("^docker pull")), 0) assert_equal(len(docker.match("--no-cache")), 2) @@ -441,6 +468,16 @@ def test_vm_build(): assert_equal(str(error.exception), "no definition for vm 'bogus'") + # The revisions come from the repository being built, so a declaration without them is reported rather than every image + # silently being keyed on nothing + with tempfile.TemporaryDirectory() as path: + _repo_write(path, container="vm:\n u24:\n os-base: debian\n") + + with assert_raises(ToolError) as error: + cmd_vm_build(_Config(path, "u24")) + + assert_equal(str(error.exception), "the 'revision' section is required in test/container.yaml") + #################################################################################################################################### def _hash(docker): diff --git a/test/src/module/test/common/vm_test.py b/test/src/module/test/common/vm_test.py index 30a0ce3b2..27555d571 100644 --- a/test/src/module/test/common/vm_test.py +++ b/test/src/module/test/common/vm_test.py @@ -1,7 +1,8 @@ """Test Virtual Machine Definitions. -The definitions in the module are checked when it is imported, so a broken definition would fail before any test here runs. The -checks are exercised again with definitions built here, since the real ones can only ever be valid.""" +The definitions in test/container.yaml are read and checked when the module is imported, so a broken declaration would fail before +any test here runs. The parsing and the checks are exercised again with declarations written here, since the real one can only ever +be valid.""" #################################################################################################################################### from harness.test import * @@ -9,6 +10,18 @@ from harness.test import * from common.error import * from common.vm import * +# A declaration with one vm in it, which is enough for the keys that are read one vm at a time +VM_ONE = """ +vm: + u24: + default: true + os-base: debian + image: ubuntu:24.04 + db: [17, 18] + db-test: [18] + coverage-c: true +""" + #################################################################################################################################### def _vm_map(db_test_list=None, coverage_c=True): @@ -16,10 +29,15 @@ def _vm_map(db_test_list=None, coverage_c=True): Every supported version is tested on the first vm unless the caller says otherwise.""" - result = {vm: Vm(VM_OS_BASE_DEBIAN, []) for vm in VM_LIST} - result[VM_LIST[0]] = Vm( - VM_OS_BASE_DEBIAN, list(PG_VERSION_LIST) if db_test_list is None else db_test_list, coverage_c=coverage_c - ) + result = {} + + for name in VM_LIST: + vm = Vm(name) + vm.default = True + result[name] = vm + + result[VM_LIST[0]].db_test_list = list(PG_VERSION_LIST) if db_test_list is None else db_test_list + result[VM_LIST[0]].coverage_c = coverage_c return result @@ -28,25 +46,33 @@ def _vm_map(db_test_list=None, coverage_c=True): def test_vm_get(): """A vm definition is found by name and a name with no definition is an error.""" - assert_true(vm_valid(VM_U24)) + assert_true(vm_valid("u24")) assert_false(vm_valid("bogus")) - vm = vm_get(VM_U24) + vm = vm_get("u24") + assert_equal(vm.name, "u24") assert_equal(vm.os_base, VM_OS_BASE_DEBIAN) assert_equal(vm.image, "ubuntu:24.04") assert_true(vm.pg_repo) assert_true(vm.coverage_c) # A vm that does not install from the PostgreSQL repo and tests every version it installs - vm = vm_get(VM_D12) + vm = vm_get("d12") assert_false(vm.pg_repo) assert_false(vm.coverage_c) assert_equal(vm.db_list, vm.db_test_list) - # There is no container for none, since it runs on the host - assert_is_none(vm_get(VM_NONE).image) + # There is no container for none, since it runs on the host, and it is not a vm --vm=all selects. What would be installed in a + # container is not declared for it, and neither is an os base since the os is whatever the host is. + vm = vm_get(VM_NONE) + + assert_is_none(vm.image) + assert_is_none(vm.os_base) + assert_equal(vm.db_list, []) + assert_true(vm.coverage_c) + assert_true(VM_NONE not in VM_LIST) with assert_raises(ToolError) as error: vm_get("bogus") @@ -54,6 +80,123 @@ def test_vm_get(): assert_equal(str(error.exception), "no definition for vm 'bogus'") +#################################################################################################################################### +def _parse(key): + """Parse a vm that has a container, since that is what all but a couple of the keys are for.""" + + return vm_parse("vm:\n u24:\n image: ubuntu:24.04\n%s" % key)["u24"] + + +#################################################################################################################################### +def test_vm_parse(): + """A definition is read as it is declared and what it does not declare gets a default.""" + + vm = vm_parse(VM_ONE)["u24"] + + assert_true(vm.default) + assert_equal(vm.os_base, VM_OS_BASE_DEBIAN) + assert_equal(vm.image, "ubuntu:24.04") + assert_equal(vm.db_list, ["17", "18"]) + assert_equal(vm.db_test_list, ["18"]) + assert_true(vm.coverage_c) + + # Everything that was not declared, which is what a vm that needs nothing installed or configured gets + assert_true(vm.pg_repo) + assert_true(vm.pg_repo_key) + assert_is_none(vm.pg_repo_release) + assert_false(vm.dnf_module) + assert_is_none(vm.coverage_python) + assert_true(vm.valgrind) + assert_false(vm.epel) + assert_false(vm.powertools) + assert_is_none(vm.python) + assert_false(vm.ssh_rsa) + + # Integration tests run against every version installed when the vm does not say which + vm = _parse(" os-base: debian\n db: [17, 18]\n") + + assert_equal(vm.db_test_list, ["17", "18"]) + assert_false(vm.default) + + # An unreleased version is only in the beta repository, so the container build is told which installed version needs it + assert_is_none(vm.pg_beta) + assert_true(vm_get("u24").pg_beta in vm_get("u24").db_list) + + # A vm with no image has no container, so it declares only whether it is a default and whether it collects C coverage + vm = vm_parse("vm:\n none:\n coverage-c: true\n")["none"] + + assert_is_none(vm.image) + assert_is_none(vm.os_base) + assert_equal(vm.db_list, []) + assert_equal(vm.db_test_list, []) + assert_true(vm.coverage_c) + + +#################################################################################################################################### +def test_vm_parse_error(): + """A declaration that is not one is reported, since a key that is ignored would silently change what is installed.""" + + with assert_raises(ToolError) as error: + vm_parse("bogus:\n") + + assert_equal(str(error.exception), "the 'vm' section is required in test/container.yaml") + + with assert_raises(ToolError) as error: + vm_parse("vm:\n - u24\n") + + assert_equal(str(error.exception), "the 'vm' section in test/container.yaml must be a map") + + with assert_raises(ToolError) as error: + vm_parse("vm:\n u24: bogus\n") + + assert_equal(str(error.exception), "vm 'u24' must be a map") + + with assert_raises(ToolError) as error: + _parse(" os-base: debian\n bogus: true\n") + + assert_equal(str(error.exception), "unknown key 'bogus' in vm 'u24'") + + with assert_raises(ToolError) as error: + _parse(" os-base: debian\n default: yes\n") + + assert_equal(str(error.exception), "invalid boolean 'yes' for vm 'u24' key 'default'") + + # A container has to be built on something, so an os base that is not one of them, or none at all, is reported the same way + with assert_raises(ToolError) as error: + _parse(" os-base: bogus\n") + + assert_equal(str(error.exception), "vm 'u24' must declare one of these os bases: alpine, debian, rhel") + + # A vm with no container is not built, so a key that says how one would be built could never be used + with assert_raises(ToolError) as error: + vm_parse("vm:\n none:\n os-base: debian\n") + + assert_equal(str(error.exception), "vm 'none' has no container so it may not declare 'os-base'") + + with assert_raises(ToolError) as error: + _parse(" os-base: debian\n coverage-python: bogus\n") + + assert_equal(str(error.exception), "invalid python coverage 'bogus' in vm 'u24'") + + # A version written on its own is a string, which would be read as a list of the characters in it + with assert_raises(ToolError) as error: + _parse(" os-base: debian\n db: 17\n") + + assert_equal(str(error.exception), "vm 'u24' key 'db' must be a list") + + # A version no interface is generated for could never be tested, so it is a typo rather than a version + with assert_raises(ToolError) as error: + _parse(" os-base: debian\n db: [17, 99]\n") + + assert_equal(str(error.exception), "PostgreSQL 99 in vm 'u24' is not a supported version") + + # A version that is tested but not installed would be selected for an integration test that could never run + with assert_raises(ToolError) as error: + _parse(" os-base: debian\n db: [17]\n db-test: [18]\n") + + assert_equal(str(error.exception), "PostgreSQL 18 tested on vm 'u24' is not installed there") + + #################################################################################################################################### def test_vm_check(): """Definitions that leave a version or C coverage untested are an error.""" @@ -68,7 +211,7 @@ def test_vm_check(): # A version tested on more than one vm is an error since the extra run is just slower with nothing added vm_map = _vm_map() - vm_map[VM_LIST[1]] = Vm(VM_OS_BASE_DEBIAN, [PG_VERSION_LIST[0]]) + vm_map[VM_LIST[1]].db_test_list = [PG_VERSION_LIST[0]] with assert_raises(ToolError) as error: vm_check(vm_map)