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)