| 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 |