blob: ce1c2464f270b6b09ffc62de4fbdf6892622dc21 [file]
import inspect
import logging
import os
import re
import shutil
import signal
import socket
import subprocess
import threading
import time
from typing import Callable, Dict, List, Optional
from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
log = logging.getLogger(__name__)
class SystemdTestSetup(HttpdTestSetup):
def __init__(self, env: 'HttpdTestEnv'):
super().__init__(env=env)
self.add_source_dir(os.path.dirname(inspect.getfile(SystemdTestSetup)))
# mod_systemd is only built with --enable-systemd, so it must not be
# a hard requirement; the tests skip when it is absent.
self.add_optional_modules(["systemd"])
class SystemdTestEnv(HttpdTestEnv):
def __init__(self, pytestconfig=None):
super().__init__(pytestconfig=pytestconfig)
self.add_httpd_log_modules(["core"])
self._notify = None
def setup_httpd(self, setup: HttpdTestSetup = None):
super().setup_httpd(setup=SystemdTestSetup(env=self))
@property
def systemd_is_dso(self) -> bool:
return os.path.isfile(os.path.join(self.libexec_dir, 'mod_systemd.so'))
@property
def has_systemd_module(self) -> bool:
"""Whether mod_systemd is available, however it was built:
--enable-systemd links it statically, --enable-systemd=shared
builds the DSO."""
if self.systemd_is_dso:
return True
p = subprocess.run([self.httpd_bin, '-l'], capture_output=True,
text=True)
return re.search(r'^\s+mod_systemd\.c$', p.stdout, re.M) is not None
@property
def notify(self) -> 'NotifyListener':
"""The stand-in notification socket httpd reports to."""
return self._notify
def start_notify_listener(self) -> 'NotifyListener':
"""Bind the notification socket and point httpd's $NOTIFY_SOCKET at it.
This must happen before the server is first started, since libsystemd
reads $NOTIFY_SOCKET from the environment of the httpd process.
"""
assert self._notify is None
self._notify = NotifyListener(
os.path.join(self.server_dir, 'systemd-notify.sock'))
self.set_httpd_env('NOTIFY_SOCKET', self._notify.path)
return self._notify
def stop_notify_listener(self):
if self._notify is not None:
self._notify.close()
self._notify = None
def server_env(self) -> Dict[str, str]:
"""The environment httpd is started with, as apachectl gets it."""
return self._clean_path_env()
@property
def httpd_bin(self) -> str:
return os.path.join(self.bin_dir, 'httpd')
class NotifyListener:
"""A stand-in for the systemd notification socket.
sd_notify(3) does nothing more than send a datagram to the AF_UNIX
socket named by $NOTIFY_SOCKET, so an unconnected datagram socket is
enough to observe everything mod_systemd reports, with no systemd
instance and no privileges involved.
Datagrams are drained by a background thread so that the periodic
notifications from the monitor hook cannot fill the socket buffer
while a test is doing something else.
"""
def __init__(self, path: str):
# sockaddr_un is limited to 108 bytes; well within reach for a
# source tree in a home directory, but check rather than fail
# obscurely inside bind().
assert len(path) < 100, f"notification socket path too long: {path}"
if os.path.exists(path):
os.unlink(path)
self.path = path
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
self._sock.bind(path)
self._sock.settimeout(0.1)
self._lock = threading.Lock()
self._messages: List[Dict[str, str]] = []
self._stop = threading.Event()
self._thread = threading.Thread(target=self._drain, daemon=True)
self._thread.start()
def _drain(self):
while not self._stop.is_set():
try:
data = self._sock.recv(8192)
except socket.timeout:
continue
except OSError:
break
try:
msg = self.parse(data)
except Exception as ex:
# Never let one unreadable datagram stop the listener: the
# tests would then see silence rather than a failure.
log.warning(f"undecodable notification {data!r}: {ex}")
continue
log.debug(f"notify: {msg}")
with self._lock:
self._messages.append(msg)
def close(self):
self._stop.set()
self._thread.join(timeout=2)
self._sock.close()
if os.path.exists(self.path):
os.unlink(self.path)
@staticmethod
def parse(data: bytes) -> Dict[str, str]:
"""Split one notification datagram into its NAME=VALUE assignments.
The last line carries no trailing newline in the MAINPID
notification, and a value may itself contain '='.
"""
msg = {}
for line in data.decode(errors='replace').split('\n'):
name, sep, value = line.partition('=')
if sep:
msg[name] = value
return msg
@property
def messages(self) -> List[Dict[str, str]]:
with self._lock:
return list(self._messages)
def clear(self):
with self._lock:
self._messages.clear()
def wait_for(self, match: Callable[[Dict[str, str]], bool],
timeout: float = 5.0) -> Optional[Dict[str, str]]:
"""Return the first message satisfying `match`, waiting for it to
arrive if it has not already. Returns None on timeout."""
end = time.time() + timeout
seen = 0
while True:
with self._lock:
pending = self._messages[seen:]
seen = len(self._messages)
for msg in pending:
if match(msg):
return msg
if time.time() >= end:
return None
time.sleep(0.05)
def wait_for_key(self, key: str, timeout: float = 5.0) \
-> Optional[Dict[str, str]]:
return self.wait_for(lambda m: key in m, timeout=timeout)
def wait_for_status(self, pattern: str, timeout: float = 5.0) \
-> Optional[Dict[str, str]]:
rx = re.compile(pattern)
return self.wait_for(lambda m: 'STATUS' in m and rx.search(m['STATUS']),
timeout=timeout)
def statuses(self) -> List[str]:
return [m['STATUS'] for m in self.messages if 'STATUS' in m]
# The STATUS= line the monitor hook reports, from systemd_monitor() in
# modules/arch/unix/mod_systemd.c. Idle and busy are the percentages
# ap_get_sload() computes, and are -1 when there are no workers at all.
MONITOR_STATUS = re.compile(
r'^Total requests: (?P<requests>\d+);\s*'
r'Idle/Busy workers (?P<idle>-?\d+)%/(?P<busy>-?\d+)%;\s*'
r'Requests/sec: (?P<rate>\S+);\s*'
r'Bytes served/sec: (?P<bps>.*)B/sec$')
# ap_run_monitor() is called every INTERVAL_OF_WRITABLE_PROBES (10) turns of
# the ~1s parent loop in ap_wait_or_timeout(), so a status update is up to
# roughly 10 seconds away. Waiting for one costs that; waiting to be sure
# none is coming costs the whole timeout, so keep it to a small multiple.
MONITOR_TIMEOUT = 25.0
# Showing that no report is coming costs the whole wait, so it only has to
# comfortably outlast one turn of that cycle.
NO_MONITOR_TIMEOUT = 15.0
# The keep-alive notification is sent from the same monitor hook, so waiting
# for one costs the same as waiting for a status report.
WATCHDOG_TIMEOUT = MONITOR_TIMEOUT
# The shortest WatchdogSec mod_systemd will accept without complaining, which
# is twice the monitor interval: the recommended keep-alive period is half the
# watchdog timeout, and half of anything shorter than this is out of reach of a
# hook which runs every ten seconds. Keep in step with mod_systemd.c.
SYSTEMD_MONITOR_INTERVAL = 10
MIN_WATCHDOG_SEC = 2 * SYSTEMD_MONITOR_INTERVAL
def is_watchdog_ping(msg: Dict[str, str]) -> bool:
"""Whether a notification carries the keep-alive ping.
mod_systemd is free to send WATCHDOG=1 in a datagram of its own or
alongside whatever else it is reporting, so match on the assignment
rather than on the message being only that.
"""
return msg.get('WATCHDOG') == '1'
# A configuration for a server run directly rather than through apachectl,
# sharing the server root, module list and error log with the rest of the
# suite but with its own pid file and port.
STANDALONE_CONF = """
ServerRoot "${server_dir}"
DefaultRuntimeDir logs
PidFile "${pidfile}"
Include "conf/${modules_conf}"
ServerName standalone.test
ErrorLog "logs/error_log"
LogLevel ${loglevel}
DocumentRoot "${server_dir}/htdocs"
<Directory "${server_dir}/htdocs">
Require all granted
</Directory>
<IfModule mod_ssl.c>
SSLSessionCache "shmcb:ssl_gcache_data(32000)"
</IfModule>
${extra}
Listen ${port}
"""
def write_server_conf(env: SystemdTestEnv, name: str, port: int,
modules_conf: str = 'modules.conf',
extra: str = '') -> str:
"""Write a standalone configuration and return its path."""
path = os.path.join(env.server_conf_dir, f'{name}.conf')
with open(path, 'w') as fd:
fd.write(STANDALONE_CONF
.replace('${server_dir}', env.server_dir)
.replace('${pidfile}',
os.path.join(env.server_logs_dir, f'{name}.pid'))
.replace('${loglevel}', 'debug' if env.verbosity else 'warn')
.replace('${modules_conf}', modules_conf)
.replace('${extra}', extra)
.replace('${port}', str(port)))
return path
def http_responds(port: int, timeout: float = 2.0) -> bool:
"""One HTTP request, without curl, so that a listening socket which
nothing is serving cannot be mistaken for a running server: under socket
activation the listener exists before httpd does."""
try:
with socket.create_connection(('127.0.0.1', port), 1.0) as c:
c.settimeout(timeout)
c.sendall(b'GET / HTTP/1.0\r\nHost: standalone.test\r\n\r\n')
return c.recv(64).startswith(b'HTTP/1.')
except OSError:
return False
def error_log_size(env: SystemdTestEnv) -> int:
"""Where the shared error log ends now, so that a later read can take
only what one operation wrote."""
try:
return os.path.getsize(env.httpd_error_log.path)
except OSError:
return 0
def error_log_since(env: SystemdTestEnv, pos: int) -> str:
"""The error log written since error_log_size() returned pos."""
try:
with open(env.httpd_error_log.path, errors='replace') as fd:
fd.seek(pos)
return fd.read()
except OSError:
return ''
class ActivatedServer:
"""An httpd handed a listening socket the way a service manager does.
The protocol is only $LISTEN_FDS descriptors starting at 3, and
$LISTEN_PID naming the process they were meant for, so the test opens
the socket and speaks it directly. systemd-socket-activate would do
the same, but its --now option is too recent to rely on, and this
needs no systemd tooling at all.
apachectl cannot pass descriptors, so httpd is run directly, in the
foreground: LISTEN_PID has to be the process which calls
sd_listen_fds(), and a daemonised parent would not be it.
"""
def __init__(self, env: SystemdTestEnv, port: int, name: str = 'activate',
extra: str = '', listen_port: int = None,
modules_conf: str = 'modules.conf'):
self.env = env
self.port = port
# The port systemd-socket-activate binds, which is the same as the
# configured one unless a test wants them to disagree.
self.listen_port = port if listen_port is None else listen_port
self.name = name
self.pid_file = os.path.join(env.server_logs_dir, f'{name}.pid')
self.proc = None
self.stdout = None
self.stderr = None
self.conf_file = write_server_conf(env, name, port,
modules_conf=modules_conf,
extra=extra)
@staticmethod
def modules_conf_without(env: SystemdTestEnv, module: str) -> str:
"""Write a copy of the generated module list with one module left
out, to check what happens when it is not loaded."""
name = f'modules-no-{module}.conf'
src = os.path.join(env.server_conf_dir, 'modules.conf')
rx = re.compile(rf'^\s*LoadModule\s+{module}_module\b')
with open(src) as fd:
lines = [l for l in fd if not rx.match(l)]
with open(os.path.join(env.server_conf_dir, name), 'w') as fd:
fd.writelines(lines)
return name
def args(self, fd: int) -> List[str]:
# The shell moves the inherited socket to descriptor 3 and names
# itself in LISTEN_PID before exec'ing httpd in its place, which is
# the one thing this cannot do from the parent: the pid has to be
# the one which will call sd_listen_fds(). bash rather than sh
# because dash parses only one digit in a redirection, and the
# socket lands well above descriptor 9.
return [
'bash', '-c',
f'exec 3<&{fd}; export LISTEN_FDS=1 LISTEN_FDNAMES=activate '
'LISTEN_PID=$$; exec "$0" "$@"',
self.env.httpd_bin, '-DFOREGROUND',
'-d', self.env.server_dir, '-f', self.conf_file,
]
def start(self) -> 'ActivatedServer':
lsock = socket.create_server(('', self.listen_port),
family=socket.AF_INET6,
dualstack_ipv6=True, backlog=128)
try:
# A new session so that the whole group can be signalled on the
# way out.
self.proc = subprocess.Popen(
self.args(lsock.fileno()), env=self.env.server_env(),
start_new_session=True, pass_fds=(lsock.fileno(),),
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
finally:
# The child holds it now; keeping a copy here would leave the
# port bound after the server is gone.
lsock.close()
return self
def _reap(self):
"""Collect the output of a server which has exited."""
if self.stderr is None and self.proc.poll() is not None:
self.stdout, self.stderr = self.proc.communicate()
def wait_exit(self, timeout: float = 10.0) -> int:
"""Wait for a server which is expected to fail to start."""
self.stdout, self.stderr = self.proc.communicate(timeout=timeout)
return self.proc.returncode
def is_live(self, timeout: float = 10.0) -> bool:
end = time.time() + timeout
while True:
if self.proc.poll() is not None:
# Collect its diagnostics, so that a test reporting the
# server did not come up can say why.
self._reap()
return False
if http_responds(self.port):
return True
if time.time() >= end:
return False
time.sleep(0.2)
def is_running(self, settle: float = 2.0) -> bool:
"""Whether the server is still up once it has had time to fail.
A restart which cannot find its sockets takes a few milliseconds to
bring the parent down, and its old children go on serving after it,
so neither an immediate check nor a request proves anything.
"""
end = time.time() + settle
while time.time() < end:
if self.proc.poll() is not None:
return False
time.sleep(0.1)
return True
def reload(self) -> int:
"""Ask the running server to restart gracefully."""
r = self.env.run([self.env.httpd_bin, '-d', self.env.server_dir,
'-f', self.conf_file, '-k', 'graceful'],
env=self.env.server_env())
return r.exit_code
def _signal_group(self, sig: int) -> bool:
"""Signal every process still in the server's group, reporting
whether any remained. start_new_session() made the process we
launched the group leader, so its pid is the group id whether or
not it is still alive."""
try:
os.killpg(self.proc.pid, sig)
return True
except OSError:
return False
def stop(self):
if self.proc is None:
return
self._signal_group(signal.SIGTERM)
if self.proc.poll() is None:
try:
self.stdout, self.stderr = self.proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
self._signal_group(signal.SIGKILL)
self.stdout, self.stderr = self.proc.communicate()
# A parent which died during a failed restart leaves its children
# behind, still holding the listening socket and still answering.
# They have to go too, or the next test finds the port taken.
end = time.time() + 5
while self._signal_group(0):
if time.time() >= end:
self._signal_group(signal.SIGKILL)
break
time.sleep(0.1)
def __enter__(self) -> 'ActivatedServer':
return self.start()
def __exit__(self, *args):
self.stop()
# Type=notify-reload and ReloadSignal= both arrived in systemd 253. An
# unrecognised Type= does not degrade to anything, it stops the unit loading
# at all, so there is nothing to fall back to and the tests are skipped.
NOTIFY_RELOAD_VERSION = 253
def systemd_version() -> int:
"""The version of the systemd on this host, or 0 if it cannot be asked.
"systemctl --version" opens with "systemd 259 (259.8-1.fc44)"."""
try:
p = subprocess.run(['systemctl', '--version'], capture_output=True,
text=True, timeout=15)
except (OSError, subprocess.TimeoutExpired):
return 0
m = re.match(r'systemd (\d+)', p.stdout)
return int(m.group(1)) if m else 0
class TransientService:
"""httpd run as a real transient systemd unit, with systemd-run.
This is the only harness here which exercises the notification protocol
against systemd itself rather than a stand-in socket: systemd provides
NOTIFY_SOCKET, holds the service in "activating" until READY=1 arrives,
tracks MAINPID, and shows the reported STATUS= as the unit's status
text. It needs a per-user service manager, which a login session has
but a bare CI container does not.
service_type selects what is exercised: "notify" for startup and
shutdown, "notify-reload" for the reload protocol on top of them.
"""
def __init__(self, env: SystemdTestEnv, port: int,
name: str = None, extra: str = '',
service_type: str = 'notify', exec_reload: bool = True,
properties: List[str] = None):
self.env = env
self.port = port
self.unit = name or f'httpd-test-{os.getpid()}'
self.service_type = service_type
self.exec_reload = exec_reload
self.conf_file = write_server_conf(env, 'transient', port, extra=extra)
self.pid_file = os.path.join(env.server_logs_dir, 'transient.pid')
# Extra --property arguments for the unit, such as WatchdogSec= or
# ReloadSignal=.
self.properties = list(properties or [])
def read_pid(self) -> Optional[int]:
try:
with open(self.pid_file) as fd:
return int(fd.read().strip())
except (OSError, ValueError):
return None
@staticmethod
def is_available() -> bool:
"""Whether this user has a systemd manager to run services under."""
if shutil.which('systemd-run') is None:
return False
if not os.environ.get('XDG_RUNTIME_DIR'):
return False
# Talking to the manager at all is the test: without a login
# session, or with lingering off, there is none to talk to.
try:
return subprocess.run(['systemctl', '--user', 'show', '-p',
'Version'], capture_output=True,
timeout=15).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def systemctl(self, *args,
timeout: float = 60.0) -> subprocess.CompletedProcess:
return subprocess.run(['systemctl', '--user', *args],
capture_output=True, text=True, timeout=timeout)
def show(self, prop: str) -> str:
r = self.systemctl('show', '-p', prop, '--value', f'{self.unit}.service')
return r.stdout.strip()
def start(self, timeout: float = 20.0) -> subprocess.CompletedProcess:
httpd = self.env.httpd_bin
props = [
f'--service-type={self.service_type}',
'--property=KillMode=mixed',
# A reload which is never reported finished holds the job open
# until this elapses, so keep it to the same bound as the start.
f'--property=TimeoutStartSec={int(timeout)}',
]
if self.exec_reload:
props.append(f'--property=ExecReload={httpd} '
f'-d {self.env.server_dir} '
f'-f {self.conf_file} -k graceful')
props += [f'--property={p}' for p in self.properties]
r = subprocess.run([
'systemd-run', '--user', '--collect', '--quiet',
'--unit', self.unit, *props,
httpd, '-DFOREGROUND',
'-d', self.env.server_dir, '-f', self.conf_file,
], capture_output=True, text=True, timeout=timeout)
return r
def reload(self, timeout: float = 60.0) -> subprocess.CompletedProcess:
"""Ask the manager to reload the unit. Under Type=notify-reload
this returns once the server has reported the reload finished;
under Type=notify, as soon as ExecReload= has exited."""
return self.systemctl('reload', f'{self.unit}.service',
timeout=timeout)
def rewrite_conf(self, extra: str = ''):
"""Replace the configuration the unit reads on its next reload.
The path does not change, so ExecStart= and ExecReload= still name
it."""
self.conf_file = write_server_conf(self.env, 'transient', self.port,
extra=extra)
def wait_active(self, timeout: float = 20.0) -> bool:
end = time.time() + timeout
while time.time() < end:
if self.show('ActiveState') == 'active':
return True
time.sleep(0.2)
return False
def stop(self):
self.systemctl('stop', f'{self.unit}.service')
self.systemctl('reset-failed', f'{self.unit}.service')
def __enter__(self) -> 'TransientService':
return self
def __exit__(self, *args):
self.stop()
class ForegroundServer(ActivatedServer):
"""httpd run directly in the foreground, with extra environment.
The watchdog protocol is keyed to a process id: sd_watchdog_enabled(3)
ignores $WATCHDOG_USEC unless $WATCHDOG_PID is unset or names the
process reading it. apachectl cannot be used to set that, since the
variable has to name the parent httpd and the pid is not known until it
exists, so the value is assigned in a shell which then exec's httpd in
its own place -- the same trick ActivatedServer uses for $LISTEN_PID.
Assignments are shell words, so "$$" in a value is the pid httpd will
have.
"""
def __init__(self, env: SystemdTestEnv, port: int,
name: str = 'foreground', extra: str = '',
setenv: Dict[str, str] = None,
modules_conf: str = 'modules.conf'):
super().__init__(env, port, name=name, extra=extra,
modules_conf=modules_conf)
self.setenv = dict(setenv or {})
def args(self, fd: int = None) -> List[str]:
assigns = ' '.join(f'{k}={v}' for k, v in self.setenv.items())
return [
'bash', '-c',
f'export {assigns}; exec "$0" "$@"' if assigns else 'exec "$0" "$@"',
self.env.httpd_bin, '-DFOREGROUND',
'-d', self.env.server_dir, '-f', self.conf_file,
]
def start(self) -> 'ForegroundServer':
# A new session, so the whole group can be signalled on the way out
# even when a failed restart leaves children behind.
self.proc = subprocess.Popen(
self.args(), env=self.env.server_env(), start_new_session=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return self