spawn.py 5.07 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
"""
Provide an automatic way of spawing an HTTP test server running aurweb.

It can be called from the command-line or from another Python module.

This module uses a global state, since you can’t open two servers with the same
configuration anyway.
"""


import argparse
12
import atexit
13
import os
14
import os.path
15
16
import subprocess
import sys
17
import tempfile
18
19
20
21
22
23
24
import time
import urllib

import aurweb.config
import aurweb.schema

children = []
25
temporary_dir = None
26
27
28
29
30
31
32
33
verbosity = 0


class ProcessExceptions(Exception):
    """
    Compound exception used by stop() to list all the errors that happened when
    terminating child processes.
    """
Kevin Morris's avatar
Kevin Morris committed
34

35
36
37
38
39
40
41
    def __init__(self, message, exceptions):
        self.message = message
        self.exceptions = exceptions
        messages = [message] + [str(e) for e in exceptions]
        super().__init__("\n- ".join(messages))


42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def generate_nginx_config():
    """
    Generate an nginx configuration based on aurweb's configuration.
    The file is generated under `temporary_dir`.
    Returns the path to the created configuration file.
    """
    aur_location = aurweb.config.get("options", "aur_location")
    aur_location_parts = urllib.parse.urlsplit(aur_location)
    config_path = os.path.join(temporary_dir, "nginx.conf")
    config = open(config_path, "w")
    # We double nginx's braces because they conflict with Python's f-strings.
    config.write(f"""
        events {{}}
        daemon off;
        error_log /dev/stderr info;
        pid {os.path.join(temporary_dir, "nginx.pid")};
        http {{
            access_log /dev/stdout;
            server {{
                listen {aur_location_parts.netloc};
                location / {{
                    proxy_pass http://{aurweb.config.get("php", "bind_address")};
                }}
65
66
67
                location /sso {{
                    proxy_pass http://{aurweb.config.get("fastapi", "bind_address")};
                }}
68
69
70
71
72
73
            }}
        }}
    """)
    return config_path


74
75
76
def spawn_child(args):
    """Open a subprocess and add it to the global state."""
    if verbosity >= 1:
77
        print(f":: Spawning {args}", file=sys.stderr)
78
79
80
81
82
83
84
85
86
87
88
89
90
    children.append(subprocess.Popen(args))


def start():
    """
    Spawn the test server. If it is already running, do nothing.

    The server can be stopped with stop(), or is automatically stopped when the
    Python process ends using atexit.
    """
    if children:
        return
    atexit.register(stop)
91

92
93
94
    if 'AUR_CONFIG' in os.environ:
        os.environ['AUR_CONFIG'] = os.path.realpath(os.environ['AUR_CONFIG'])

95
96
97
98
    try:
        terminal_width = os.get_terminal_size().columns
    except OSError:
        terminal_width = 80
99
100
101
102
103
    print("{ruler}\n"
          "Spawing PHP and FastAPI, then nginx as a reverse proxy.\n"
          "Check out {aur_location}\n"
          "Hit ^C to terminate everything.\n"
          "{ruler}"
104
          .format(ruler=("-" * terminal_width),
105
106
107
108
109
110
111
112
113
                  aur_location=aurweb.config.get('options', 'aur_location')))

    # PHP
    php_address = aurweb.config.get("php", "bind_address")
    htmldir = aurweb.config.get("php", "htmldir")
    spawn_child(["php", "-S", php_address, "-t", htmldir])

    # FastAPI
    host, port = aurweb.config.get("fastapi", "bind_address").rsplit(":", 1)
Kevin Morris's avatar
Kevin Morris committed
114
    spawn_child(["python", "-m", "hypercorn", "-b", f"{host}:{port}",
115
116
117
118
                 "aurweb.asgi:app"])

    # nginx
    spawn_child(["nginx", "-p", temporary_dir, "-c", generate_nginx_config()])
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135


def stop():
    """
    Stop all the child processes.

    If an exception occurs during the process, the process continues anyway
    because we don’t want to leave runaway processes around, and all the
    exceptions are finally raised as a single ProcessExceptions.
    """
    global children
    atexit.unregister(stop)
    exceptions = []
    for p in children:
        try:
            p.terminate()
            if verbosity >= 1:
136
                print(f":: Sent SIGTERM to {p.args}", file=sys.stderr)
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
        except Exception as e:
            exceptions.append(e)
    for p in children:
        try:
            rc = p.wait()
            if rc != 0 and rc != -15:
                # rc = -15 indicates the process was terminated with SIGTERM,
                # which is to be expected since we called terminate on them.
                raise Exception(f"Process {p.args} exited with {rc}")
        except Exception as e:
            exceptions.append(e)
    children = []
    if exceptions:
        raise ProcessExceptions("Errors terminating the child processes:",
                                exceptions)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        prog='python -m aurweb.spawn',
        description='Start aurweb\'s test server.')
    parser.add_argument('-v', '--verbose', action='count', default=0,
                        help='increase verbosity')
    args = parser.parse_args()
    verbosity = args.verbose
162
163
164
165
166
167
168
169
    with tempfile.TemporaryDirectory(prefix="aurweb-") as tmpdirname:
        temporary_dir = tmpdirname
        start()
        try:
            while True:
                time.sleep(60)
        except KeyboardInterrupt:
            stop()