1
0
mirror of https://github.com/apache/httpd.git synced 2026-10-23 22:49:01 +03:00
Files
apache/test/pyhttpd/env.py
Jean-Frederic Clere 4bc8d94974 Add a test for Bug 69743
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1939335 13f79535-47bb-0310-9956-ffa450edef68
2026-10-22 12:42:23 +00:00

1491 lines
56 KiB
Python

import importlib
import inspect
import logging
import re
import os
import signal
import shutil
import stat
import subprocess
import sys
import time
import platform
from datetime import datetime, timedelta
from string import Template
from typing import List, Optional
from configparser import ConfigParser, ExtendedInterpolation
from urllib.parse import urlparse
from .certs import Credentials, HttpdTestCA, CertificateSpec
from .log import HttpdErrorLog
from .nghttp import Nghttp
from .result import ExecResult
log = logging.getLogger(__name__)
class Dummy:
pass
class HttpdTestSetup:
# the modules we want to load
MODULES = [
"log_config",
"logio",
"unixd",
"version",
"watchdog",
"authn_core",
"authz_host",
"authz_groupfile",
"authz_user",
"authz_core",
"access_compat",
"auth_basic",
"cache",
"cache_disk",
"cache_socache",
"socache_shmcb",
"dumpio",
"reqtimeout",
"filter",
"mime",
"env",
"headers",
"setenvif",
"slotmem_shm",
"status",
"dir",
"alias",
"rewrite",
"deflate",
"proxy",
"proxy_http",
]
CURL_STDOUT_SEPARATOR = "===CURL_STDOUT_SEPARATOR==="
def __init__(self, env: 'HttpdTestEnv'):
self.env = env
self._source_dirs = [os.path.dirname(inspect.getfile(HttpdTestSetup))]
self._modules = HttpdTestSetup.MODULES.copy()
self._optional_modules = []
def add_source_dir(self, source_dir):
self._source_dirs.append(source_dir)
def add_modules(self, modules: List[str]):
self._modules.extend(modules)
def add_optional_modules(self, modules: List[str]):
self._optional_modules.extend(modules)
def _cgi_module(self):
"""The name of a CGI module this build has: mod_cgid for preference,
mod_cgi otherwise. There is no mod_cgid on Windows."""
if sys.platform != "win32" \
and os.path.isfile(os.path.join(self.env.libexec_dir,
"mod_cgid.so")):
return "cgid"
return "cgi"
def add_cgi_module(self):
"""Load a CGI module, for a suite which needs CGI but does not care
which of mod_cgid and mod_cgi runs it."""
self.add_modules([self._cgi_module()])
def make(self):
self._make_dirs()
self._make_conf()
if self.env.mpm_module is not None \
and self.env.mpm_module in self.env.mpm_modules:
self.add_modules([self.env.mpm_module])
if self.env.ssl_module is not None:
self.add_modules([self.env.ssl_module])
self._make_modules_conf()
self._make_htdocs()
self._add_aptest()
self._build_clients()
self.env.clear_curl_headerfiles()
def _make_dirs(self):
if not os.path.exists(self.env.gen_dir):
os.makedirs(self.env.gen_dir)
# wipe logs from any previous module run to avoid stale files in the archive
if os.path.isdir(self.env.server_logs_dir):
shutil.rmtree(self.env.server_logs_dir,
ignore_errors=(sys.platform == "win32"))
os.makedirs(self.env.server_logs_dir, exist_ok=True)
def _make_conf(self):
# remove anything from another run/test suite
conf_dest_dir = os.path.join(self.env.server_dir, 'conf')
if os.path.isdir(conf_dest_dir):
shutil.rmtree(conf_dest_dir)
for d in self._source_dirs:
conf_src_dir = os.path.join(d, 'conf')
if os.path.isdir(conf_src_dir):
if not os.path.exists(conf_dest_dir):
os.makedirs(conf_dest_dir)
for name in os.listdir(conf_src_dir):
src_path = os.path.join(conf_src_dir, name)
m = re.match(r'(.+).template', name)
if m:
self._make_template(src_path, os.path.join(conf_dest_dir, m.group(1)))
elif os.path.isfile(src_path):
shutil.copy(src_path, os.path.join(conf_dest_dir, name))
def _make_template(self, src, dest):
var_map = dict()
for name, value in HttpdTestEnv.__dict__.items():
if isinstance(value, property):
var_map[name] = value.fget(self.env)
t = Template(''.join(open(src).readlines()))
with open(dest, 'w') as fd:
fd.write(t.substitute(var_map))
def _get_builtin_modules(self) -> set:
httpd_bin = os.path.join(self.env.bin_dir, 'httpd')
p = subprocess.run([httpd_bin, '-l'], capture_output=True, text=True)
builtin = set()
for line in p.stdout.splitlines():
match = re.match(r'^\s+mod_(.+)\.c$', line)
if match:
builtin.add(match.group(1))
return builtin
def _make_modules_conf(self):
loaded = set()
builtin_modules = self._get_builtin_modules()
modules_conf = os.path.join(self.env.server_dir, 'conf/modules.conf')
with open(modules_conf, 'w') as fd:
# issue load directives for all modules we want that are shared
missing_mods = list()
modules = self._modules
modules = [self._cgi_module() if m == "cgid" else m
for m in modules]
for m in modules:
match = re.match(r'^mod_(.+)$', m)
if match:
m = match.group(1)
if m in loaded:
continue
mod_path = os.path.join(self.env.libexec_dir, f"mod_{m}.so")
if m in builtin_modules:
fd.write(f"#built-in: LoadModule {m}_module \"{mod_path}\"\n")
elif os.path.isfile(mod_path):
fd.write(f"LoadModule {m}_module \"{mod_path}\"\n")
elif m in self.env.dso_modules:
missing_mods.append(m)
else:
fd.write(f"#built static: LoadModule {m}_module \"{mod_path}\"\n")
loaded.add(m)
for m in self._optional_modules:
match = re.match(r'^mod_(.+)$', m)
if match:
m = match.group(1)
if m in loaded:
continue
mod_path = os.path.join(self.env.libexec_dir, f"mod_{m}.so")
if m not in builtin_modules and os.path.isfile(mod_path):
fd.write(f"LoadModule {m}_module \"{mod_path}\"\n")
loaded.add(m)
if len(missing_mods) > 0:
raise Exception(f"Unable to find modules: {missing_mods} "
f"DSOs: {self.env.dso_modules}")
def _make_htdocs(self):
if not os.path.exists(self.env.server_docs_dir):
os.makedirs(self.env.server_docs_dir)
dest_dir = os.path.join(self.env.server_dir, 'htdocs')
# remove anything from another run/test suite
if os.path.isdir(dest_dir):
shutil.rmtree(dest_dir)
for d in self._source_dirs:
srcdocs = os.path.join(d, 'htdocs')
if os.path.isdir(srcdocs):
shutil.copytree(srcdocs, dest_dir, dirs_exist_ok=True)
for dirpath, _dirnames, filenames in os.walk(dest_dir):
for fname in filenames:
fpath = os.path.join(dirpath, fname)
if sys.platform == "win32":
# normalize line endings to LF so test assertions match
try:
data = open(fpath, 'rb').read()
if b'\r\n' in data and b'\x00' not in data:
open(fpath, 'wb').write(data.replace(b'\r\n', b'\n'))
except OSError:
pass
if re.search(r'\.(py|cgi)$', fname):
if sys.platform == "win32":
self._fix_shebang(fpath)
st = os.stat(fpath)
os.chmod(fpath, st.st_mode | stat.S_IEXEC)
@staticmethod
def _fix_shebang(py_file):
python_path = sys.executable
python_path = python_path.replace("\\", "/")
with open(py_file, 'r') as f:
lines = f.readlines()
if lines and re.match(r'#!\s*/usr/bin/env\s+python', lines[0]):
lines[0] = f"#!{python_path}\n"
with open(py_file, 'w', newline='\n') as f:
f.writelines(lines)
def _add_aptest(self):
module_dir = self.env.test_modules_dir
if not self.env.isWindows:
local_dir = os.path.dirname(inspect.getfile(HttpdTestSetup))
p = subprocess.run([self.env.apxs, '-c', 'mod_aptest.c'],
capture_output=True,
cwd=os.path.join(local_dir, 'mod_aptest'))
rv = p.returncode
if rv != 0:
log.error(f"compiling mod_aptest failed: {p.stderr}")
raise Exception(f"compiling mod_aptest failed: {p.stderr}")
module_dir = os.path.join(local_dir, "mod_aptest", ".libs")
modules_conf = os.path.join(self.env.server_dir, 'conf/modules.conf')
with open(modules_conf, 'a') as fd:
# load our test module
fd.write(f"LoadModule aptest_module \"{module_dir}/mod_aptest.so\"\n")
def _build_clients(self):
if self.env.test_modules_dir:
return
clients_dir = os.path.join(
os.path.dirname(os.path.dirname(inspect.getfile(HttpdTestSetup))),
'clients')
p = subprocess.run(['make'], capture_output=True, cwd=clients_dir)
rv = p.returncode
if rv != 0:
log.error(f"compiling test clients failed: {p.stderr}")
raise Exception(f"compiling test clients failed: {p.stderr}")
class HttpdTestEnv:
LIBEXEC_DIR = None
# entries shared across all modules, archived once in _shared
SHARED_SERVER_ENTRIES = {'ca', 'ca1', 'ca2', 'md', 'acme-ca.pem',
'eab.json'}
@staticmethod
def has_tool(name: str) -> bool:
return bool(shutil.which(name))
@classmethod
def has_python_package(cls, name: str) -> bool:
if name in sys.modules:
# already loaded
return True
elif (spec := importlib.util.find_spec(name)) is not None:
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return True
else:
return False
@classmethod
def get_ssl_module(cls):
return os.environ['SSL'] if 'SSL' in os.environ else 'mod_ssl'
@classmethod
def has_shared_module(cls, name):
if cls.LIBEXEC_DIR is None:
env = HttpdTestEnv() # will initialized it
path = os.path.join(cls.LIBEXEC_DIR, f"mod_{name}.so")
return os.path.isfile(path)
@classmethod
def has_mod_ssl(cls):
"""Whether mod_ssl is installed.
Not the same question as get_ssl_module(), which only reports which
TLS module $SSL selected for this run. A test that is "only for
mod_ssl" is not applicable under SSL=mod_tls and should skip; a test
that needs mod_ssl to be installed at all has a real dependency.
"""
return cls.has_shared_module("ssl")
_directives = None
def has_directive(self, name: str) -> bool:
if HttpdTestEnv._directives is None:
httpd_bin = os.path.join(self._bin_dir, 'httpd')
try:
p = subprocess.run([httpd_bin, '-L'],
capture_output=True, text=True)
HttpdTestEnv._directives = p.stdout
except Exception:
HttpdTestEnv._directives = ""
return name in HttpdTestEnv._directives
_nghttp_gate = {}
@classmethod
def needs_nghttp(cls, assets: bool = False):
"""A marker gating a test, class or module on the nghttp client.
nghttp is the only client that can drive PUSH, trailers and explicit
frame padding, so the tests that use it have no fallback. It is named
unconditionally in config.ini, which says nothing about whether it is
installed, so the binary has to be run to find out.
:param assets: also require the '-a' asset-fetching support, which
nghttp only has when built against libxml2
"""
from .depends import needs_dependency
if assets not in cls._nghttp_gate:
env = HttpdTestEnv()
ok = env.has_nghttp_get_assets() if assets else env.has_nghttp()
if assets:
reason = "nghttp not available or misses -a (needs libxml2)"
else:
reason = "nghttp not available"
cls._nghttp_gate[assets] = needs_dependency(
"nghttp", ok, reason=reason,
detected=f"tried to run {env._nghttp}")
return cls._nghttp_gate[assets]
_h2load_gate = {}
@classmethod
def needs_h2load(cls, minv: str = None, reason: str = None):
"""A marker gating a test, class or module on the h2load client.
:param minv: also require at least this h2load version. 1.41.0 is
the one that matters here, it brought --connect-to.
:param reason: skip reason, when the default wording is too vague
about what the version is needed for.
"""
from .depends import needs_dependency
key = (minv, reason)
if key not in cls._h2load_gate:
env = HttpdTestEnv()
if minv is None:
ok = env.has_h2load()
dep = "h2load"
default_reason = "h2load not available"
else:
ok = env.h2load_is_at_least(minv)
dep = f"h2load >= {minv}"
default_reason = f"h2load unavailable or older than {minv}"
found = env.h2load_version_str()
cls._h2load_gate[key] = needs_dependency(
dep, ok, reason=reason or default_reason,
detected=(f"h2load {found}" if found is not None
else f"tried to run {env._h2load}"))
return cls._h2load_gate[key]
_multipart_gate = None
@classmethod
def needs_multipart(cls):
"""A marker gating a test, class or module on the multipart package.
The CGI helpers in htdocs/cgi/ parse file uploads with it. They
import it only for a multipart/ body, so this belongs on the tests
that upload a file, not on everything that touches a CGI.
"""
from .depends import needs_dependency
if cls._multipart_gate is None:
cls._multipart_gate = needs_dependency(
"multipart", cls.has_python_package('multipart'),
reason="no multipart python package available",
detected="python package 'multipart' not installed "
"(pip install python-multipart)")
return cls._multipart_gate
def __init__(self, pytestconfig=None):
self._our_dir = os.path.dirname(inspect.getfile(Dummy))
self._config_ini = os.getenv("PYHTTPD_CONFIG",
os.path.join(self._our_dir, 'config.ini'))
self.config = ConfigParser(interpolation=ExtendedInterpolation())
self.config.read(self._config_ini)
self._bin_dir = self.config.get('global', 'bindir')
self._apxs = self.config.get('global', 'apxs')
self._prefix = self.config.get('global', 'prefix')
self._apachectl = self.config.get('global', 'apachectl')
self._libexec_dir = self.config.get('global', 'libexecdir')
HttpdTestEnv.LIBEXEC_DIR = self._libexec_dir
if HttpdTestEnv.LIBEXEC_DIR is None:
HttpdTestEnv.LIBEXEC_DIR = self._libexec_dir = self.get_apxs_var('LIBEXECDIR')
self._httpd_ver = self.config.get('httpd', 'version')
self._curl = self.config.get('global', 'curl_bin')
if 'CURL' in os.environ:
self._curl = os.environ['CURL']
self._nghttp = self.config.get('global', 'nghttp')
if self._nghttp is None:
self._nghttp = 'nghttp'
self._h2load = self.config.get('global', 'h2load')
if self._h2load is None:
self._h2load = 'h2load'
self._test_modules_dir = self.config.get('global', 'pre_built_test_binaries_dir',
fallback=None)
self._http_port = int(self.config.get('test', 'http_port'))
self._http_port2 = int(self.config.get('test', 'http_port2'))
self._https_port = int(self.config.get('test', 'https_port'))
self._proxy_port = int(self.config.get('test', 'proxy_port'))
self._ws_port = int(self.config.get('test', 'ws_port'))
self._http_tld = self.config.get('test', 'http_tld')
self._test_dir = self.config.get('test', 'test_dir')
# In cmake determining the test directory is a headache and this is a simple workaround
if not self._test_dir:
self._test_dir = os.getcwd() + "/pyhttpd" if "pyhttpd" not in os.getcwd() else os.getcwd()
self._clients_dir = os.path.join(os.path.dirname(self._test_dir), 'clients')
self._gen_dir = self.config.get('test', 'gen_dir')
self._server_dir = os.path.join(self._gen_dir, 'apache')
self._server_conf_dir = os.path.join(self._server_dir, "conf")
self._server_docs_dir = os.path.join(self._server_dir, "htdocs")
self._server_logs_dir = os.path.join(self._server_dir, "logs")
self._server_run_dir = os.path.join(self._server_dir, "run")
self._server_access_log = os.path.join(self._server_logs_dir, "access_log")
error_log_sep = "." if self.isWindows else "_"
self._error_log = HttpdErrorLog(os.path.join(self._server_logs_dir, f"error{error_log_sep}log"))
self._apachectl_stderr = None
self._httpd_proc = None
self._apply_cli_overrides(pytestconfig)
self._dso_modules = self.config.get('httpd', 'dso_modules').split(' ')
self._mpm_modules = self.config.get('httpd', 'mpm_modules').split(' ')
self._mpm_module = f"mpm_{os.environ['MPM']}" if 'MPM' in os.environ else 'mpm_winnt' if self.isWindows else 'mpm_event'
self._ssl_module = self.get_ssl_module()
if len(self._ssl_module.strip()) == 0:
self._ssl_module = None
self._httpd_addr = "127.0.0.1"
self._http_base = f"http://{self._httpd_addr}:{self.http_port}"
self._https_base = f"https://{self._httpd_addr}:{self.https_port}"
self._verbosity = pytestconfig.option.verbose if pytestconfig is not None else 0
# imported here, not at module scope, to keep env.py free of a
# top-level pytest dependency: it is also built outside pytest runs
from .depends import strict_optional_enabled
self._strict_optional = strict_optional_enabled(pytestconfig)
self._test_conf = os.path.join(self._server_conf_dir, "test.conf")
self._httpd_base_conf = []
self._httpd_env = {}
self._httpd_log_modules = ['aptest']
self._log_interesting = None
self._setup = None
self._ca = None
self._cert_specs = [CertificateSpec(domains=[
f"test1.{self._http_tld}",
f"test2.{self._http_tld}",
f"test3.{self._http_tld}",
f"cgi.{self._http_tld}",
], key_type='rsa4096')]
self._verify_certs = False
self._curl_headerfiles_n = 0
self._curl_version = None
self._h2load_version = None
self._nghttp_version = None
self._current_test = None
def _apply_cli_overrides(self, pytestconfig):
"""Override config.ini values with --httpd / --conf CLI options.
config.ini is generated by the build system from config.ini.in and
is never rewritten. When --httpd or --conf are passed on the
command line the corresponding variables are overridden in memory.
"""
if pytestconfig is None:
return
httpd_path = pytestconfig.getoption("--httpd", default=None)
conf_path = pytestconfig.getoption("--conf", default=None)
if httpd_path:
httpd_path = os.path.abspath(httpd_path)
prefix = os.path.dirname(os.path.dirname(httpd_path))
self._bin_dir = os.path.dirname(httpd_path)
self._prefix = prefix
self._libexec_dir = os.path.join(prefix, "modules")
HttpdTestEnv.LIBEXEC_DIR = self._libexec_dir
if self.isWindows:
self._apachectl = httpd_path
else:
ctl = os.path.join(self._bin_dir, "apachectl")
self._apachectl = ctl if os.path.isfile(ctl) else httpd_path
self._apxs = os.path.join(self._bin_dir, "apxs")
try:
p = subprocess.run([httpd_path, "-v"],
capture_output=True, text=True)
m = re.search(r"Apache/(\S+)", p.stdout)
if m:
self._httpd_ver = m.group(1)
except Exception:
pass
if conf_path:
conf_path = os.path.abspath(conf_path)
dso = []
mpm = []
try:
with open(conf_path) as f:
for line in f:
line = line.strip()
if line.startswith("#"):
continue
m = re.match(r"LoadModule\s+(\S+)_module", line)
if m:
name = m.group(1)
if name.startswith("mpm_"):
mpm.append(name)
else:
dso.append(name)
except Exception:
pass
if dso:
self.config.set('httpd', 'dso_modules', ' '.join(dso))
if mpm:
self.config.set('httpd', 'mpm_modules', ' '.join(mpm))
def add_httpd_conf(self, lines: List[str]):
self._httpd_base_conf.extend(lines)
def add_httpd_log_modules(self, modules: List[str]):
self._httpd_log_modules.extend(modules)
def set_httpd_env(self, name: str, value: Optional[str]):
"""Add a variable to the environment httpd is started with, or
remove it again when passed None.
Used by tests for modules which take their input from the
environment rather than from the configuration, such as
mod_systemd reading $NOTIFY_SOCKET.
"""
if value is None:
self._httpd_env.pop(name, None)
else:
self._httpd_env[name] = value
def issue_certs(self):
if self._ca is None:
self._ca = HttpdTestCA.create_root(name=self.http_tld,
store_dir=os.path.join(self.server_dir, 'ca'),
key_type="rsa4096")
self._ca.issue_certs(self._cert_specs)
def setup_httpd(self, setup: HttpdTestSetup = None):
"""Create the server environment with config, htdocs and certificates"""
self._setup = setup if setup is not None else HttpdTestSetup(env=self)
self._setup.make()
self.issue_certs()
if self._httpd_log_modules:
if self._verbosity >= 2:
log_level = "trace2"
elif self._verbosity >= 1:
log_level = "debug"
else:
log_level = "info"
self._log_interesting = "LogLevel"
for name in self._httpd_log_modules:
self._log_interesting += f" {name}:{log_level}"
def check_error_log(self):
errors, warnings = self._error_log.get_missed()
assert (len(errors), len(warnings)) == (0, 0),\
f"apache logged {len(errors)} errors and {len(warnings)} warnings: \n"\
"{0}\n{1}\n".format("\n".join(errors), "\n".join(warnings))
# combines httpd version & revision to identify the archive folder
def _archive_version(self):
if not hasattr(self, '_cached_archive_version'):
version = self.get_httpd_version()
r = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'],
capture_output=True, text=True,
cwd=self._our_dir)
if r.returncode == 0:
version = f"{version}-{r.stdout.strip()}"
else:
r = subprocess.run(['svn', 'info', '--show-item', 'revision'],
capture_output=True, text=True,
cwd=self._our_dir)
if r.returncode == 0:
version = f"{version}-r{r.stdout.strip()}"
self._cached_archive_version = version
return self._cached_archive_version
def archive_logs(self, package_name, archive_dir):
version = self._archive_version()
dest = os.path.join(archive_dir, version, package_name)
os.makedirs(dest, exist_ok=True)
# merge into existing dest to preserve per-test confs
shutil.copytree(self._server_dir, dest, ignore=self.ignore_files,
dirs_exist_ok=True)
shared_dest = os.path.join(archive_dir, version, '_shared')
if os.path.isdir(shared_dest):
shutil.rmtree(shared_dest)
os.makedirs(shared_dest)
for entry in self.SHARED_SERVER_ENTRIES:
src = os.path.join(self._server_dir, entry)
entry_dest = os.path.join(shared_dest, entry)
if os.path.isdir(src):
shutil.copytree(src, entry_dest)
elif os.path.isfile(src):
shutil.copy2(src, entry_dest)
shared_conf_dest = os.path.join(shared_dest, 'conf')
os.makedirs(shared_conf_dest)
# copy them once
for f in ['httpd.conf', 'mime.types', 'stop.conf']:
src = os.path.join(self._server_conf_dir, f)
if os.path.isfile(src):
shutil.copy2(src, shared_conf_dest)
def archive_test_conf(self, test_name, package_name, archive_dir):
version = self._archive_version()
dest = os.path.join(archive_dir, version, package_name, 'conf')
if not os.path.isdir(dest):
os.makedirs(dest)
test_conf = os.path.join(self._server_conf_dir, 'test.conf')
if os.path.isfile(test_conf):
safe_name = test_name.replace('/', '_').replace('\\', '_')
dest_file = os.path.join(dest, f"{safe_name}.conf")
shutil.copy(test_conf, dest_file)
# return files to ignore
def ignore_files(self, d, entries):
ignored = [e for e in entries if
e.endswith('.sock') or e in self.SHARED_SERVER_ENTRIES]
# shared confs goes to _shared
# test.conf is captured per-test
if os.path.basename(d) == 'conf':
ignored += ['httpd.conf', 'mime.types', 'stop.conf', 'test.conf']
# htdocs are static test fixtures not useful in the archive
if os.path.basename(d) == os.path.basename(self._server_dir):
ignored += ['htdocs']
return ignored
@property
def curl(self) -> str:
return self._curl
@property
def apxs(self) -> str:
return self._apxs
@property
def verbosity(self) -> int:
return self._verbosity
@property
def prefix(self) -> str:
return self._prefix
@property
def mpm_module(self) -> str:
return self._mpm_module
@property
def ssl_module(self) -> str:
return self._ssl_module
@property
def http_addr(self) -> str:
return self._httpd_addr
@property
def http_port(self) -> int:
return self._http_port
@property
def http_port2(self) -> int:
return self._http_port2
@property
def https_port(self) -> int:
return self._https_port
@property
def proxy_port(self) -> int:
return self._proxy_port
@property
def ws_port(self) -> int:
return self._ws_port
@property
def http_tld(self) -> str:
return self._http_tld
@property
def http_base_url(self) -> str:
return self._http_base
@property
def https_base_url(self) -> str:
return self._https_base
@property
def bin_dir(self) -> str:
return self._bin_dir
@property
def gen_dir(self) -> str:
return self._gen_dir
@property
def test_dir(self) -> str:
return self._test_dir
@property
def clients_dir(self) -> str:
return self._clients_dir
@property
def server_dir(self) -> str:
return self._server_dir
@property
def server_logs_dir(self) -> str:
return self._server_logs_dir
@property
def server_run_dir(self) -> str:
return self._server_run_dir
@property
def libexec_dir(self) -> str:
return HttpdTestEnv.LIBEXEC_DIR
@property
def dso_modules(self) -> List[str]:
return self._dso_modules
@property
def mpm_modules(self) -> List[str]:
return self._mpm_modules
@property
def server_conf_dir(self) -> str:
return self._server_conf_dir
@property
def server_docs_dir(self) -> str:
return self._server_docs_dir
@property
def httpd_error_log(self) -> HttpdErrorLog:
return self._error_log
def htdocs_src(self, path):
return os.path.join(self._our_dir, 'htdocs', path)
@property
def h2load(self) -> str:
return self._h2load
@property
def ca(self) -> Credentials:
return self._ca
@property
def current_test_name(self) -> str:
return self._current_test
def set_current_test_name(self, val) -> None:
self._current_test = val
def require(self, condition: bool, reason: str,
dep: str = None, detected=None) -> None:
"""Skip, or fail under strict-optional mode, on a missing dependency.
The in-test counterpart of the @needs_dependency decorator, for
checks that can only be made once a test is running. Use it instead
of a bare pytest.skip() whenever the reason is "something we expected
to be installed is not", and a plain pytest.skip() when the reason is
"this test does not apply here".
:param condition: truthy if the dependency is present
:param reason: skip reason, also the "reason" line when strict
:param dep: short dependency name for the strict message
:param detected: what was found instead, for the strict message
"""
import pytest
if condition:
return
if self._strict_optional:
from .depends import strict_failure_message
pytest.fail(strict_failure_message(
dep or reason, expected=reason, detected=detected,
where=self.current_test_name), pytrace=False)
pytest.skip(reason)
@property
def apachectl_stderr(self) -> str:
return self._apachectl_stderr
@property
def test_modules_dir(self) -> str:
return self._test_modules_dir
@property
def isWindows(self) -> bool:
return platform.system().lower() == "windows"
def add_cert_specs(self, specs: List[CertificateSpec]):
self._cert_specs.extend(specs)
def get_credentials_for_name(self, dns_name) -> List['Credentials']:
for spec in [s for s in self._cert_specs if s.domains is not None]:
if dns_name in spec.domains:
return self.ca.get_credentials_for_name(spec.domains[0])
return []
def _versiontuple(self, v):
v = re.sub(r'(\d+\.\d+(\.\d+)?)(-\S+)?', r'\1', v)
return tuple(map(int, v.split('.')))
def httpd_is_at_least(self, minv):
hv = self._versiontuple(self.get_httpd_version())
return hv >= self._versiontuple(minv)
@staticmethod
def _run_version(args):
"""Run a --version style command, returning its stdout or None.
None means the binary is not there or would not run, which callers
must distinguish from "ran fine but is too old". config.ini/default
may just be the bare command name ("h2load"), not a verified path,
so catching OSError here is what keeps an uninstalled tool from
crashing collection with FileNotFoundError.
"""
try:
p = subprocess.run(args, capture_output=True, text=True)
except OSError:
return None
return p.stdout if p.returncode == 0 else None
def _probe_h2load(self):
"""The h2load version tuple, or None when h2load is unusable."""
if self._h2load_version is None:
self._h2load_version = False # probed, not found
out = self._run_version([self._h2load, '--version']) if self._h2load else None
if out is not None:
m = re.match(r'h2load nghttp2/(\S+)', out.strip())
if m:
self._h2load_version = self._versiontuple(m.group(1))
return self._h2load_version or None
def has_h2load(self):
return self._probe_h2load() is not None
def h2load_version_str(self):
"""'1.41.0', or None when h2load is absent. For strict messages."""
v = self._probe_h2load()
return None if v is None else ".".join(str(n) for n in v)
def h2load_is_at_least(self, minv):
v = self._probe_h2load()
return v is not None and v >= self._versiontuple(minv)
def _probe_curl(self):
"""The curl version tuple, or None when curl is unusable."""
if self._curl_version is None:
self._curl_version = False # probed, not found
out = self._run_version([self._curl, '-V']) if self._curl else None
if out is not None:
for l in out.splitlines():
m = re.match(r'curl ([0-9.]+)[- ].*', l)
if m:
self._curl_version = self._versiontuple(m.group(1))
break
return self._curl_version or None
def has_curl(self):
return self._probe_curl() is not None
def curl_version_str(self):
"""'8.4.0', or None when curl is absent. For strict messages."""
v = self._probe_curl()
return None if v is None else ".".join(str(n) for n in v)
def curl_is_at_least(self, minv):
v = self._probe_curl()
return v is not None and v >= self._versiontuple(minv)
def curl_is_less_than(self, version):
v = self._probe_curl()
return v is not None and v < self._versiontuple(version)
def curl_is_8_1_x(self):
"""Whether curl is one of the 8.1.x releases.
Those mishandle TLS renegotiation; 8.0.x and 8.2.0+ are both fine.
Returns False when curl is absent, so combine it with has_curl() when
the test also needs curl to exist at all.
"""
v = self._probe_curl()
return (v is not None
and self._versiontuple('8.1.0') <= v < self._versiontuple('8.2.0'))
def _probe_nghttp(self):
"""The nghttp version tuple, or None when nghttp is unusable.
config.ini always names a binary, so the name alone proves nothing
about whether nghttp is installed: it has to be run. 'nghttp
--version' prints 'nghttp2/1.64.0', the nghttp2 library version.
"""
if self._nghttp_version is None:
self._nghttp_version = False # probed, not found
out = self._run_version([self._nghttp, '--version']) if self._nghttp else None
if out is not None:
m = re.search(r'nghttp2/(\S+)', out.strip())
if m:
self._nghttp_version = self._versiontuple(m.group(1))
return self._nghttp_version or None
def has_nghttp(self):
return self._probe_nghttp() is not None
def nghttp_version_str(self):
"""'1.64.0', or None when nghttp is absent. For strict messages."""
v = self._probe_nghttp()
return None if v is None else ".".join(str(n) for n in v)
def nghttp_is_at_least(self, minv):
v = self._probe_nghttp()
return v is not None and v >= self._versiontuple(minv)
def has_nghttp_get_assets(self):
if not self.has_nghttp():
return False
p = subprocess.run([self._nghttp, "-a"], capture_output=True, text=True)
return p.returncode == 0 and p.stderr == ""
def get_apxs_var(self, name: str) -> str:
p = subprocess.run([self._apxs, "-q", name], capture_output=True, text=True)
if p.returncode != 0:
return ""
return p.stdout.strip()
def get_httpd_version(self) -> str:
return self._httpd_ver
def mkpath(self, path):
if not os.path.exists(path):
return os.makedirs(path)
def run(self, args, stdout_list=False, intext=None, inbytes=None, debug_log=True, env=None):
if debug_log:
log.debug(f"run: {args}")
start = datetime.now()
if intext is not None:
inbytes = intext.encode()
p = subprocess.run(args, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
input=inbytes, env=env)
stdout_as_list = None
if stdout_list:
try:
out = p.stdout.decode()
if HttpdTestSetup.CURL_STDOUT_SEPARATOR in out:
stdout_as_list = out.split(HttpdTestSetup.CURL_STDOUT_SEPARATOR)
if not stdout_as_list[len(stdout_as_list) - 1]:
stdout_as_list.pop()
p.stdout.replace(HttpdTestSetup.CURL_STDOUT_SEPARATOR.encode(), b'')
except:
pass
return ExecResult(args=args, exit_code=p.returncode,
stdout=p.stdout, stderr=p.stderr,
stdout_as_list=stdout_as_list,
duration=datetime.now() - start)
def mkurl(self, scheme, hostname, path='/'):
port = self.https_port if scheme == 'https' else self.http_port
return f"{scheme}://{hostname}.{self.http_tld}:{port}{path}"
def install_test_conf(self, lines: List[str]):
self.apache_stop()
with open(self._test_conf, 'w') as fd:
fd.write('\n'.join(self._httpd_base_conf))
fd.write('\n')
fd.write(f"CoreDumpDirectory {self._server_dir}\n")
fd.write('\n')
if self._verbosity >= 3:
fd.write(f"LogLevel trace7 ssl:trace6\n")
fd.write(f"DumpIoOutput on\n")
fd.write(f"DumpIoInput on\n")
elif self._verbosity >= 2:
fd.write(f"LogLevel debug core:trace5 {self.mpm_module}:trace5 ssl:trace5 http:trace5\n")
elif self._verbosity >= 1:
fd.write(f"LogLevel info\n")
else:
fd.write(f"LogLevel warn\n")
if self._log_interesting:
fd.write(self._log_interesting)
fd.write('\n\n')
fd.write('\n'.join(lines))
fd.write('\n')
def is_live(self, url: str = None, timeout: timedelta = None):
if url is None:
url = self._http_base
if timeout is None:
timeout = timedelta(seconds=5)
try_until = datetime.now() + timeout
last_err = ""
while datetime.now() < try_until:
# noinspection PyBroadException
try:
r = self.curl_get(url, insecure=True)
if r.exit_code == 0:
return True
time.sleep(.1)
except ConnectionRefusedError:
log.debug("connection refused")
time.sleep(.1)
except:
if last_err != str(sys.exc_info()[0]):
last_err = str(sys.exc_info()[0])
log.debug("Unexpected error: %s", last_err)
time.sleep(.1)
log.debug(f"Unable to contact server after {timeout}")
return False
def is_dead(self, url: str = None, timeout: timedelta = None):
if url is None:
url = self._http_base
if timeout is None:
timeout = timedelta(seconds=5)
try_until = datetime.now() + timeout
last_err = None
while datetime.now() < try_until:
# noinspection PyBroadException
try:
r = self.curl_get(url)
if r.exit_code != 0:
return True
time.sleep(.1)
except ConnectionRefusedError:
log.debug("connection refused")
return True
except:
if last_err != str(sys.exc_info()[0]):
last_err = str(sys.exc_info()[0])
log.debug("Unexpected error: %s", last_err)
time.sleep(.1)
log.debug(f"Server still responding after {timeout}")
return False
def _clean_path_env(self) -> dict:
# Strip shim directories (uv, plugin-injected) from PATH so CGI
# scripts spawned by httpd resolve python3 to a real interpreter.
# Also prepend the test-suite venv's bin/ if present so CGI scripts
# pick up packages installed there (e.g. multipart).
parts = [
p for p in os.environ.get('PATH', '').split(os.pathsep)
if '/shims' not in p
]
venv_bin = os.path.join(self._our_dir, '.venv', 'bin')
if os.path.isdir(venv_bin) and venv_bin not in parts:
parts.insert(0, venv_bin)
env = os.environ.copy()
env['PATH'] = os.pathsep.join(parts)
env.update(self._httpd_env)
return env
def _run_apachectl(self, cmd) -> ExecResult:
conf_file = 'stop.conf' if cmd == 'stop' else 'httpd.conf'
args = [self._apachectl,
"-d", self.server_dir,
"-f", os.path.join(self._server_dir, 'conf', conf_file),
"-k", cmd]
r = self.run(args, env=self._clean_path_env())
self._apachectl_stderr = r.stderr
if r.exit_code != 0:
log.warning(f"failed: {r}")
return r
def _win_httpd_args(self):
httpd = os.path.join(self._bin_dir, 'httpd')
return [httpd,
"-d", self.server_dir,
"-f", os.path.join(self.server_dir, 'conf', 'httpd.conf'),
"-DFOREGROUND"]
def _win_start(self) -> int:
self._win_stop()
args = self._win_httpd_args()
log.debug(f"starting httpd: {args}")
self._httpd_proc = subprocess.Popen(args, env=self._clean_path_env())
time.sleep(1)
rc = self._httpd_proc.poll()
if rc is not None:
log.error(f"httpd exited immediately with code {rc}")
self._httpd_proc = None
return -1
timeout = timedelta(seconds=30)
if self.is_live(self._http_base, timeout=timeout):
return 0
log.error(f"httpd not responding at {self._http_base}")
rc = self._httpd_proc.poll()
if rc is not None:
log.error(f"httpd process died with code {rc}")
return -1
def _win_stop(self) -> int:
if self._httpd_proc is not None:
import ctypes
log.debug("stopping httpd (signal shutdown event)")
event_name = f"ap{self._httpd_proc.pid}_shutdown"
handle = ctypes.windll.kernel32.OpenEventW(0x0002, False, event_name)
if handle:
ctypes.windll.kernel32.SetEvent(handle)
ctypes.windll.kernel32.CloseHandle(handle)
else:
log.warning(f"cannot open {event_name}, falling back to terminate")
self._httpd_proc.terminate()
try:
self._httpd_proc.wait(timeout=30)
except subprocess.TimeoutExpired:
self._httpd_proc.kill()
self._httpd_proc.wait(timeout=5)
self._httpd_proc = None
if not self.is_dead(self._http_base, timeout=timedelta(seconds=60)):
log.warning("port still in use after stop")
return 0
def _win_signal_restart(self, cmd: str) -> int:
import ctypes
if self._httpd_proc is None:
log.error("no httpd process to signal")
return -1
event_name = f"ap{self._httpd_proc.pid}_restart"
log_pos = self.httpd_error_log.current_pos()
handle = ctypes.windll.kernel32.OpenEventW(0x0002, False, event_name)
if not handle:
log.error(f"cannot open event {event_name}")
return -1
ctypes.windll.kernel32.SetEvent(handle)
ctypes.windll.kernel32.CloseHandle(handle)
timeout = timedelta(seconds=10)
if not self.httpd_error_log.wait_for(self.RE_RESUMING, log_pos,
timeout=timeout.total_seconds()):
log.warning(f"no restart logged after '{cmd}' within {timeout}")
return -1
return 0 if self.is_live(self._http_base, timeout=timeout) else -1
# Logged by every MPM once the new generation is serving.
RE_RESUMING = re.compile(r'.* configured -- resuming normal operations$')
def _apache_signal_restart(self, cmd: str) -> int:
"""Have the running parent restart, gracefully or not, and wait
for the new generation to be serving. "httpd -k restart" only
sends the signal; a request answered straight afterwards may have
been served by the old generation, with the old config, and about
to be killed."""
log_pos = self.httpd_error_log.current_pos()
r = self._run_apachectl(cmd)
if r.exit_code != 0:
return r.exit_code
timeout = timedelta(seconds=10)
if not self.httpd_error_log.wait_for(self.RE_RESUMING, log_pos,
timeout=timeout.total_seconds()):
log.warning(f"no restart logged after '{cmd}' within {timeout}")
return -1
return 0 if self.is_live(self._http_base, timeout=timeout) else -1
def apache_reload(self):
if self.isWindows:
return self._win_signal_restart("graceful")
return self._apache_signal_restart("graceful")
def apache_restart(self):
if self.isWindows:
self._win_stop()
return self._win_start()
self.apache_stop()
r = self._run_apachectl("start")
if r.exit_code == 0:
timeout = timedelta(seconds=10)
return 0 if self.is_live(self._http_base, timeout=timeout) else -1
return r.exit_code
def apache_stop(self):
if self.isWindows:
self._win_stop()
timeout = timedelta(seconds=10)
return 0 if self.is_dead(self._http_base, timeout=timeout) else -1
r = self._run_apachectl("stop")
if r.exit_code == 0:
timeout = timedelta(seconds=10)
return 0 if self.is_dead(self._http_base, timeout=timeout) else -1
return r
def apache_graceful_stop(self):
log.debug("stop apache")
if self.isWindows:
self._win_stop()
return 0 if self.is_dead() else -1
self._run_apachectl("graceful-stop")
return 0 if self.is_dead() else -1
def apache_fail(self):
log.debug("expect apache fail")
if self.isWindows:
self._win_stop()
rv = self._win_start()
return 0 if rv != 0 else (0 if self.is_dead() else -1)
self._run_apachectl("stop")
rv = self._run_apachectl("start")
if rv == 0:
rv = 0 if self.is_dead() else -1
else:
rv = 0
return rv
def apache_hard_restart(self) -> int:
"""Restart without the "graceful" flag, so the MPM starts over."""
if self.isWindows:
return self._win_signal_restart("restart")
return self._apache_signal_restart("restart")
def read_pid_file(self, name: str = 'httpd.pid') -> Optional[int]:
# Where PidFile lands depends on how the httpd under test resolves
# a relative path against DefaultRuntimeDir, which has differed
# between versions; look in both places rather than assume.
for d in (self._server_logs_dir, self._server_dir):
try:
with open(os.path.join(d, name)) as fd:
return int(fd.read().strip())
except (OSError, ValueError):
continue
return None
def apache_access_log_clear(self):
if os.path.isfile(self._server_access_log):
os.remove(self._server_access_log)
def get_ca_pem_file(self, hostname: str) -> Optional[str]:
if len(self.get_credentials_for_name(hostname)) > 0:
return self.ca.cert_file
return None
def clear_curl_headerfiles(self):
for fname in os.listdir(path=self.gen_dir):
if re.match(r'curl\.headers\.\d+', fname):
os.remove(os.path.join(self.gen_dir, fname))
self._curl_headerfiles_n = 0
def curl_resolve_args(self, url, insecure=False, force_resolve=True, options=None):
u = urlparse(url)
args = [
]
if u.scheme == 'http':
pass
elif insecure:
args.append('--insecure')
elif options and "--cacert" in options:
pass
elif u.hostname:
ca_pem = self.get_ca_pem_file(u.hostname)
if ca_pem:
args.extend(["--cacert", ca_pem])
if force_resolve and u.hostname and u.hostname != 'localhost' \
and u.hostname != self._httpd_addr \
and not re.match(r'^(\d+|\[|:).*', u.hostname):
assert u.port, f"port not in url: {url}"
args.extend(["--resolve", f"{u.hostname}:{u.port}:{self._httpd_addr}"])
return args
def curl_complete_args(self, urls, stdout_list=False,
timeout=None, options=None,
insecure=False, force_resolve=True):
headerfile = f"{self.gen_dir}/curl.headers.{self._curl_headerfiles_n}"
self._curl_headerfiles_n += 1
args = [
self._curl, "-s", "--path-as-is", "-D", headerfile,
]
args.extend(self.curl_resolve_args(urls[0], insecure=insecure,
force_resolve=force_resolve,
options=options))
if stdout_list:
args.extend(['-w', '%{stdout}' + HttpdTestSetup.CURL_STDOUT_SEPARATOR])
if self._current_test is not None:
args.extend(["-H", f'AP-Test-Name: {self._current_test}'])
if timeout is not None and int(timeout) > 0:
args.extend(["--connect-timeout", str(int(timeout))])
if options:
args.extend(options)
return args, headerfile
def curl_parse_headerfile(self, headerfile: str, r: ExecResult = None) -> ExecResult:
lines = open(headerfile).readlines()
if r is None:
r = ExecResult(args=[], exit_code=0, stdout=b'', stderr=b'')
response = None
def fin_response(response):
if response:
r.add_response(response)
expected = ['status']
for line in lines:
if re.match(r'^$', line):
if 'trailer' in expected:
# end of trailers
fin_response(response)
response = None
expected = ['status']
elif 'header' in expected:
# end of header, another status or trailers might follow
expected = ['status', 'trailer']
else:
assert False, f"unexpected line: {line}"
continue
if 'status' in expected:
log.debug("reading 1st response line: %s", line)
m = re.match(r'^(\S+) (\d+) (.*)$', line)
if m:
fin_response(response)
response = {
"protocol": m.group(1),
"status": int(m.group(2)),
"description": m.group(3),
"header": {},
"trailer": {},
"body": r.outraw
}
expected = ['header']
continue
if 'trailer' in expected:
m = re.match(r'^([^:]+):\s*(.*)$', line)
if m:
response['trailer'][m.group(1).lower()] = m.group(2)
continue
if 'header' in expected:
m = re.match(r'^([^:]+):\s*(.*)$', line)
if m:
response['header'][m.group(1).lower()] = m.group(2)
continue
assert False, f"unexpected line: {line}"
fin_response(response)
return r
def curl_raw(self, urls, timeout=10, options=None, insecure=False,
force_resolve=True, no_stdout_list=False):
if not isinstance(urls, list):
urls = [urls]
stdout_list = False
if len(urls) > 1 and not no_stdout_list:
stdout_list = True
args, headerfile = self.curl_complete_args(
urls=urls, stdout_list=stdout_list,
timeout=timeout, options=options, insecure=insecure,
force_resolve=force_resolve)
args += urls
r = self.run(args, stdout_list=stdout_list)
if r.exit_code == 0:
self.curl_parse_headerfile(headerfile, r=r)
if r.json:
r.response["json"] = r.json
if os.path.isfile(headerfile):
os.remove(headerfile)
return r
def curl_get(self, url, insecure=False, options=None):
return self.curl_raw([url], insecure=insecure, options=options)
def curl_upload(self, url, fpath, timeout=5, options=None):
if not options:
options = []
options.extend([
"--form", ("file=@%s" % fpath)
])
return self.curl_raw(urls=[url], timeout=timeout, options=options)
def curl_post_data(self, url, data="", timeout=5, options=None):
if not options:
options = []
options.extend(["--data", "%s" % data])
return self.curl_raw(url, timeout, options)
def curl_post_value(self, url, key, value, timeout=5, options=None):
if not options:
options = []
options.extend(["--form", "{0}={1}".format(key, value)])
return self.curl_raw(url, timeout, options)
def curl_protocol_version(self, url, timeout=5, options=None):
if not options:
options = []
devnull = "NUL" if sys.platform == "win32" else "/dev/null"
options.extend(["-w", "%{http_version}\n", "-o", devnull])
r = self.curl_raw(url, timeout=timeout, options=options)
if r.exit_code == 0 and r.response:
return r.response["body"].decode('utf-8').rstrip()
return -1
def nghttp(self):
return Nghttp(self._nghttp, connect_addr=self._httpd_addr,
tmp_dir=self.gen_dir, test_name=self._current_test)
def h2load_status(self, run: ExecResult):
stats = {}
m = re.search(
r'requests: (\d+) total, (\d+) started, (\d+) done, (\d+) succeeded'
r', (\d+) failed, (\d+) errored, (\d+) timeout', run.stdout)
if m:
stats["requests"] = {
"total": int(m.group(1)),
"started": int(m.group(2)),
"done": int(m.group(3)),
"succeeded": int(m.group(4))
}
m = re.search(r'status codes: (\d+) 2xx, (\d+) 3xx, (\d+) 4xx, (\d+) 5xx',
run.stdout)
if m:
stats["status"] = {
"2xx": int(m.group(1)),
"3xx": int(m.group(2)),
"4xx": int(m.group(3)),
"5xx": int(m.group(4))
}
run.add_results({"h2load": stats})
return run
def make_data_file(self, indir: str, fname: str, fsize: int) -> str:
fpath = os.path.join(indir, fname)
s10 = "0123456789"
s = (101 * s10) + s10[0:3]
with open(fpath, 'w', newline='') as fd:
for i in range(int(fsize / 1024)):
fd.write(f"{i:09d}-{s}\n")
remain = int(fsize % 1024)
if remain != 0:
i = int(fsize / 1024) + 1
s = f"{i:09d}-{s}\n"
fd.write(s[0:remain])
return fpath