source: mod_gnutls/test/mgstest/services.py @ 442c6a6

proxy-ticket
Last change on this file since 442c6a6 was 442c6a6, checked in by Fiona Klute <fiona.klute@…>, 8 months ago

Test suite: Detect Apache shutdown by PID file

Wait for the PID file to disappear instead of using signal 0. The
reason is that some container environments don't have a proper init
system which reaps the Apache process on shutdown. In that case the
process stays around as a zombie process and os.kill() reports it as
existing indefinitely.

  • Property mode set to 100644
File size: 6.5 KB
Line 
1# Copyright 2019 Fiona Klute
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Handling services needed for mod_gnutls tests"""
16
17import errno
18import os
19import signal
20import subprocess
21import sys
22
23from contextlib import contextmanager
24from pathlib import Path
25from time import sleep
26
27class TestService:
28    """A generic service used in the mod_gnutls test environment."""
29
30    def __init__(self, start=None, stop=None, forking=False,
31                 env=None, condition=None, check=None, pidfile=None):
32        # command to start the service
33        self.start_command = start
34        # command to stop the service (otherwise use SIGTERM)
35        self.stop_command = stop
36        # forking: expect the start command to terminate during startup
37        self.forking = forking
38        # condition: start service if the function returns true
39        self.condition = condition or (lambda: True)
40
41        # child process for non-forking services
42        self.process = None
43        # Forking processes like apache2 require a PID file for
44        # tracking. The process must delete its PID file when exiting.
45        self.pidfile = Path(pidfile) if pidfile else None
46        if not self.pidfile and self.forking:
47            raise ValueError('Forking service must provide PID file!')
48        self.pid = None
49
50        # add environment variables for a subprocess only
51        if env:
52            self.process_env = os.environ.copy()
53            for name, value in env.items():
54                self.process_env[name] = value
55        else:
56            self.process_env = None
57
58        # check: function to check if the service is up and working
59        self.check = check
60
61        # sleep step for waiting (sec)
62        self._step = int(os.environ.get('TEST_SERVICE_WAIT', 250)) / 1000
63
64    def start(self):
65        """Start the service"""
66        if not self.condition():
67            # skip
68            return
69        print(f'Starting: {self.start_command}')
70        if self.forking:
71            subprocess.run(self.start_command, check=True,
72                           env=self.process_env)
73        else:
74            self.process = subprocess.Popen(self.start_command,
75                                            env=self.process_env,
76                                            close_fds=True)
77
78    def stop(self):
79        """Order the service to stop"""
80        if not self.condition():
81            # skip
82            return
83        # Read PID file before actually sending the stop signal
84        if self.pidfile:
85            if not self.pidfile.exists():
86                print(f'Skipping stop of {self.stop_command}, no PID file!')
87                # Presumably the process isn't running, ignore.
88                return
89            self.pid = int(self.pidfile.read_text())
90        if self.stop_command:
91            print(f'Stopping: {self.stop_command}')
92            subprocess.run(self.stop_command, check=True, env=self.process_env)
93        else:
94            print(f'Stopping (SIGTERM): {self.start_command}')
95            if self.process:
96                # non-forking process: direct SIGTERM to child process
97                self.process.terminate()
98            else:
99                # forking process: SIGTERM based on PID file
100                os.kill(self.pid, signal.SIGTERM)
101
102    def wait(self):
103        """Wait for the process to actually stop after calling stop().
104
105        WARNING: Calling this method without stop() first is likely to
106        hang.
107        """
108        if self.process:
109            self.process.wait()
110            self.process = None
111        elif self.pid and self.pidfile:
112            print(f'Waiting for PID {self.pid} to delete its PID file '
113                  f'({self.pidfile})...', file=sys.stderr)
114            sys.stderr.flush()
115            while True:
116                if self.pidfile.exists():
117                    sleep(self._step)
118                else:
119                    break
120            self.pid = None
121
122    def wait_ready(self, timeout=None):
123        """Wait for the started service to be ready. The function passed to
124        the constructor as "check" is called to determine whether it is."""
125        if not self.check:
126            return
127
128        slept = 0
129        while not timeout or slept < timeout:
130            if self.check():
131                return
132            else:
133                sleep(self._step)
134                slept = slept + self._step
135        # TODO: A custom ServiceException or something would be nicer
136        # here.
137        raise TimeoutError('Waiting for service timed out!')
138
139    @contextmanager
140    def run(self):
141        """Context manager to start and stop a service. Note that entering the
142        context does not call TestService.wait_ready() on the service,
143        you must do that separately if desired.
144
145        """
146        try:
147            self.start()
148            # TODO: with async execution we could also call
149            # wait_ready() here
150            yield self
151        finally:
152            self.stop()
153            # TODO: this would really benefit from async execution
154            self.wait()
155
156
157
158class ApacheService(TestService):
159    """An Apache HTTPD instance used in the mod_gnutls test
160    environment."""
161
162    apache2 = os.environ.get('APACHE2', 'apache2')
163
164    def __init__(self, config, env=None, pidfile=None, check=None):
165        self.config = Path(config).resolve()
166        base_cmd = [self.apache2, '-f', str(self.config), '-k']
167        if not check:
168            check = self.pidfile_check
169        super(ApacheService, self).__init__(start=base_cmd + ['start'],
170                                            stop=base_cmd + ['stop'],
171                                            forking=True,
172                                            env=env,
173                                            pidfile=pidfile,
174                                            condition=self.config_exists,
175                                            check=check)
176
177    def config_exists(self):
178        return self.config.is_file()
179
180    def pidfile_check(self):
181        """Default check method for ApacheService, waits for the PID file to
182        be present."""
183        return self.pidfile.is_file()
Note: See TracBrowser for help on using the repository browser.