aurweb/aurweb/spawn.py
moson-mo 8ca63075e9
housekeep: remove PHP implementation
removal of the PHP codebase

Signed-off-by: moson-mo <mo-son@mailbox.org>
2023-04-28 16:10:32 +02:00

271 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 cant open two servers with the same
configuration anyway.
"""
import argparse
import atexit
import os
import os.path
import subprocess
import sys
import tempfile
import time
from typing import Iterable
import aurweb.config
import aurweb.schema
children = []
temporary_dir = None
verbosity = 0
asgi_backend = ""
workers = 1
FASTAPI_NGINX_PORT = int(os.environ.get("FASTAPI_NGINX_PORT", 8002))
class ProcessExceptions(Exception):
"""
Compound exception used by stop() to list all the errors that happened when
terminating child processes.
"""
def __init__(self, message, exceptions):
self.message = message
self.exceptions = exceptions
messages = [message] + [str(e) for e in exceptions]
super().__init__("\n- ".join(messages))
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.
"""
fastapi_bind = aurweb.config.get("fastapi", "bind_address")
fastapi_host = fastapi_bind.split(":")[0]
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;
client_body_temp_path {os.path.join(temporary_dir, "client_body")};
proxy_temp_path {os.path.join(temporary_dir, "proxy")};
fastcgi_temp_path {os.path.join(temporary_dir, "fastcgi")}1 2;
uwsgi_temp_path {os.path.join(temporary_dir, "uwsgi")};
scgi_temp_path {os.path.join(temporary_dir, "scgi")};
server {{
listen {fastapi_host}:{FASTAPI_NGINX_PORT};
location / {{
try_files $uri @proxy_to_app;
}}
location @proxy_to_app {{
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://{fastapi_bind};
}}
}}
}}
"""
)
return config_path
def spawn_child(args):
"""Open a subprocess and add it to the global state."""
if verbosity >= 1:
print(f":: Spawning {args}", file=sys.stderr)
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)
if "AUR_CONFIG" in os.environ:
os.environ["AUR_CONFIG"] = os.path.realpath(os.environ["AUR_CONFIG"])
try:
terminal_width = os.get_terminal_size().columns
except OSError:
terminal_width = 80
print(
"{ruler}\n"
"Spawing FastAPI, then nginx as a reverse proxy.\n"
"Check out {aur_location}\n"
"Hit ^C to terminate everything.\n"
"{ruler}".format(
ruler=("-" * terminal_width),
aur_location=aurweb.config.get("options", "aur_location"),
)
)
# FastAPI
fastapi_host, fastapi_port = aurweb.config.get("fastapi", "bind_address").rsplit(
":", 1
)
# Logging config.
aurwebdir = aurweb.config.get("options", "aurwebdir")
fastapi_log_config = os.path.join(aurwebdir, "logging.conf")
backend_args = {
"hypercorn": ["-b", f"{fastapi_host}:{fastapi_port}"],
"uvicorn": ["--host", fastapi_host, "--port", fastapi_port],
"gunicorn": [
"--bind",
f"{fastapi_host}:{fastapi_port}",
"-k",
"uvicorn.workers.UvicornWorker",
"-w",
str(workers),
],
}
backend_args = backend_args.get(asgi_backend)
spawn_child(
[
"python",
"-m",
asgi_backend,
"--log-config",
fastapi_log_config,
]
+ backend_args
+ ["aurweb.asgi:app"]
)
# nginx
spawn_child(["nginx", "-p", temporary_dir, "-c", generate_nginx_config()])
print(
f"""
> Started nginx.
>
> FastAPI backend: http://{fastapi_host}:{fastapi_port}
> FastAPI frontend: http://{fastapi_host}:{FASTAPI_NGINX_PORT}
>
> Frontends are hosted via nginx and should be preferred.
"""
)
def _kill_children(
children: Iterable, exceptions: list[Exception] = []
) -> list[Exception]:
"""
Kill each process found in `children`.
:param children: Iterable of child processes
:param exceptions: Exception memo
:return: `exceptions`
"""
for p in children:
try:
p.terminate()
if verbosity >= 1:
print(f":: Sent SIGTERM to {p.args}", file=sys.stderr)
except Exception as e:
exceptions.append(e)
return exceptions
def _wait_for_children(
children: Iterable, exceptions: list[Exception] = []
) -> list[Exception]:
"""
Wait for each process to end found in `children`.
:param children: Iterable of child processes
:param exceptions: Exception memo
:return: `exceptions`
"""
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)
return exceptions
def stop() -> None:
"""
Stop all the child processes.
If an exception occurs during the process, the process continues anyway
because we dont want to leave runaway processes around, and all the
exceptions are finally raised as a single ProcessExceptions.
:raises: ProcessException
:return: None
"""
global children
atexit.unregister(stop)
exceptions = _kill_children(children)
exceptions = _wait_for_children(children, exceptions)
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"
)
choices = ["hypercorn", "gunicorn", "uvicorn"]
parser.add_argument(
"-b",
"--backend",
choices=choices,
default="uvicorn",
help="asgi backend used to launch the python server",
)
parser.add_argument(
"-w",
"--workers",
default=1,
type=int,
help="number of workers to use in gunicorn",
)
args = parser.parse_args()
verbosity = args.verbose
asgi_backend = args.backend
workers = args.workers
with tempfile.TemporaryDirectory(prefix="aurweb-") as tmpdirname:
temporary_dir = tmpdirname
start()
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
stop()