aurweb/aurweb/spawn.py
Kevin Morris 19191fa8b5
fix: update nginx config in aurweb.spawn
Host a specific FastAPI nginx frontend as well as a PHP
nginx frontend, configurable by the (PHP|FASTAPI)_NGINX_PORT
environment variables.

Signed-off-by: Kevin Morris <kevr@0cost.org>
2021-11-20 15:47:25 -08:00

297 lines
9.4 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, List
import aurweb.config
import aurweb.schema
from aurweb.exceptions import AurwebException
children = []
temporary_dir = None
verbosity = 0
asgi_backend = ''
workers = 1
PHP_BINARY = os.environ.get("PHP_BINARY", "php")
PHP_MODULES = ["pdo_mysql", "pdo_sqlite"]
PHP_NGINX_PORT = int(os.environ.get("PHP_NGINX_PORT", 8001))
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 validate_php_config() -> None:
"""
Perform a validation check against PHP_BINARY's configuration.
AurwebException is raised here if checks fail to pass. We require
the 'pdo_mysql' and 'pdo_sqlite' modules to be enabled.
:raises: AurwebException
:return: None
"""
try:
proc = subprocess.Popen([PHP_BINARY, "-m"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
out, _ = proc.communicate()
except FileNotFoundError:
raise AurwebException(f"Unable to locate the '{PHP_BINARY}' "
"executable.")
assert proc.returncode == 0, ("Received non-zero error code "
f"{proc.returncode} from '{PHP_BINARY}'.")
modules = out.decode().splitlines()
for module in PHP_MODULES:
if module not in modules:
raise AurwebException(
f"PHP does not have the '{module}' module enabled.")
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.
"""
php_bind = aurweb.config.get("php", "bind_address")
php_host = php_bind.split(":")[0]
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 {php_host}:{PHP_NGINX_PORT};
location / {{
proxy_pass http://{php_bind};
}}
}}
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 PHP and 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')))
# PHP
php_address = aurweb.config.get("php", "bind_address")
php_host = php_address.split(":")[0]
htmldir = aurweb.config.get("php", "htmldir")
spawn_child(["php", "-S", php_address, "-t", htmldir])
# 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.
>
> PHP backend: http://{php_address}
> FastAPI backend: http://{fastapi_host}:{fastapi_port}
>
> PHP frontend: http://{php_host}:{PHP_NGINX_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()
try:
validate_php_config()
except AurwebException as exc:
print(f"error: {str(exc)}")
sys.exit(1)
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()