mirror of
https://github.com/apache/httpd.git
synced 2026-10-23 22:49:01 +03:00
Type=notify-reload. * test/modules/arch/linux/env.py (TransientService): Take the service type and whether to configure ExecReload=. (TransientService.systemctl): Take a timeout. (TransientService.reload, TransientService.rewrite_conf, error_log_size, error_log_since, systemd_version): New. * test/modules/arch/linux/README: Describe the new suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> GitHub: PR #729 git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937647 13f79535-47bb-0310-9956-ffa450edef68
639 lines
25 KiB
Python
639 lines
25 KiB
Python
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
|