aurweb.spawn: Integrate FastAPI and nginx

Message ID 20200530174107.GA260301@tsubame.mg0.fr
State New
Headers show
Series
  • aurweb.spawn: Integrate FastAPI and nginx
Related show

Commit Message

Frédéric Mangano-Tarumi May 30, 2020, 5:41 p.m. UTC
aurweb.spawn used to launch only PHP’s built-in server. Now it spawns a
dummy FastAPI application too. Since both stacks spawn their own HTTP
server, aurweb.spawn also spawns nginx as a reverse proxy to mount them
under the same base URL, defined by aur_location in the configuration.
---
 TESTING              |  3 +-
 aurweb/asgi.py       |  8 +++++
 aurweb/spawn.py      | 80 +++++++++++++++++++++++++++++++++++++-------
 conf/config.defaults |  7 ++++
 4 files changed, 85 insertions(+), 13 deletions(-)
 create mode 100644 aurweb/asgi.py

Comments

Filipe Laíns May 30, 2020, 5:58 p.m. UTC | #1
On Sat, 2020-05-30 at 19:41 +0200, Frédéric Mangano-Tarumi wrote:
> aurweb.spawn used to launch only PHP’s built-in server. Now it spawns a
> dummy FastAPI application too. Since both stacks spawn their own HTTP
> server, aurweb.spawn also spawns nginx as a reverse proxy to mount them
> under the same base URL, defined by aur_location in the configuration.
> ---
>  TESTING              |  3 +-
>  aurweb/asgi.py       |  8 +++++
>  aurweb/spawn.py      | 80 +++++++++++++++++++++++++++++++++++++-------
>  conf/config.defaults |  7 ++++
>  4 files changed, 85 insertions(+), 13 deletions(-)
>  create mode 100644 aurweb/asgi.py
> 
> diff --git a/TESTING b/TESTING
> index a5e08cb8..31e3bcbd 100644
> --- a/TESTING
> +++ b/TESTING
> @@ -12,7 +12,8 @@ INSTALL.
>  2) Install the necessary packages:
>  
>      # pacman -S --needed php php-sqlite sqlite words fortune-mod \
> -                         python python-sqlalchemy python-alembic
> +                         python python-sqlalchemy python-alembic \
> +                         python-fastapi uvicorn nginx

Add this to .gitlab-ci.yml too.

>  
>     Ensure to enable the pdo_sqlite extension in php.ini.
>  
> diff --git a/aurweb/asgi.py b/aurweb/asgi.py
> new file mode 100644
> index 00000000..8d3deedc
> --- /dev/null
> +++ b/aurweb/asgi.py
> @@ -0,0 +1,8 @@
> +from fastapi import FastAPI
> +
> +app = FastAPI()
> +
> +
> +@app.get("/sso/")
> +async def hello():
> +    return {"message": "Hello from FastAPI!"}
> diff --git a/aurweb/spawn.py b/aurweb/spawn.py
> index 5fa646b5..df6a2ed5 100644
> --- a/aurweb/spawn.py
> +++ b/aurweb/spawn.py
> @@ -10,8 +10,10 @@ configuration anyway.
>  
>  import atexit
>  import argparse
> +import os
>  import subprocess
>  import sys
> +import tempfile
>  import time
>  import urllib
>  
> @@ -20,6 +22,7 @@ import aurweb.schema
>  
>  
>  children = []
> +temporary_dir = None
>  verbosity = 0
>  
>  
> @@ -35,10 +38,42 @@ class ProcessExceptions(Exception):
>          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.
> +    """
> +    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")};
> +                }}
> +                location /sso {{
> +                    proxy_pass http://{aurweb.config.get("fastapi", "bind_address")};
> +                }}
> +            }}
> +        }}
> +    """)
> +    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)
> +        print(f":: Spawning {args}", file=sys.stderr)
>      children.append(subprocess.Popen(args))
>  
>  
> @@ -52,10 +87,29 @@ def start():
>      if children:
>          return
>      atexit.register(stop)
> -    aur_location = aurweb.config.get("options", "aur_location")
> -    aur_location_parts = urllib.parse.urlsplit(aur_location)
> -    htmldir = aurweb.config.get("options", "htmldir")
> -    spawn_child(["php", "-S", aur_location_parts.netloc, "-t", htmldir])
> +
> +    # Friendly message
> +    ruler = "-" * os.get_terminal_size().columns
> +    print(ruler)
> +    print("Spawing PHP and FastAPI, then nginx as a reverse proxy.")
> +    print("Check out " + aurweb.config.get("options", "aur_location"))
> +    print("Hit ^C to terminate everything.")
> +    print(ruler)

I would have a single print statement and replace ^C with CTRL+C. But
this is just a nitpick.

> +
> +    # 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)
> +    spawn_child(["python", "-m", "uvicorn",
> +                 "--host", host,
> +                 "--port", port,
> +                 "aurweb.asgi:app"])
> +
> +    # nginx
> +    spawn_child(["nginx", "-p", temporary_dir, "-c", generate_nginx_config()])
>  
>  
>  def stop():
> @@ -73,7 +127,7 @@ def stop():
>          try:
>              p.terminate()
>              if verbosity >= 1:
> -                print(f"Sent SIGTERM to {p.args}", file=sys.stderr)
> +                print(f":: Sent SIGTERM to {p.args}", file=sys.stderr)
>          except Exception as e:
>              exceptions.append(e)
>      for p in children:
> @@ -99,9 +153,11 @@ if __name__ == '__main__':
>                          help='increase verbosity')
>      args = parser.parse_args()
>      verbosity = args.verbose
> -    start()
> -    try:
> -        while True:
> -            time.sleep(60)
> -    except KeyboardInterrupt:
> -        stop()
> +    with tempfile.TemporaryDirectory(prefix="aurweb-") as tmpdirname:
> +        temporary_dir = tmpdirname
> +        start()
> +        try:
> +            while True:
> +                time.sleep(60)
> +        except KeyboardInterrupt:
> +            stop()
> diff --git a/conf/config.defaults b/conf/config.defaults
> index 86fe765c..ed495168 100644
> --- a/conf/config.defaults
> +++ b/conf/config.defaults
> @@ -41,9 +41,16 @@ cache = none
>  cache_pkginfo_ttl = 86400
>  memcache_servers = 127.0.0.1:11211
>  
> +[php]
> +; Address PHP should bind when spawned in development mode by aurweb.spawn.
> +bind_address = 127.0.0.1:8081
>  ; Directory containing aurweb's PHP code, required by aurweb.spawn.
>  ;htmldir = /path/to/web/html
>  
> +[fastapi]
> +; Address uvicorn should bind when spawned in development mode by aurweb.spawn.
> +bind_address = 127.0.0.1:8082
> +
>  [ratelimit]
>  request_limit = 4000
>  window_length = 86400

Otherwise, LGTM.

Filipe Laíns
Lukas Fleischer May 31, 2020, 2:16 p.m. UTC | #2
Thanks a lot for the patch; two minor comments inline in addition to
what Filipe mentioned previously (I am fine with ^C instead of CTRL+C by
the way but don't care much either way).

On Sat, 30 May 2020 at 13:41:07, Frédéric Mangano-Tarumi wrote:
> aurweb.spawn used to launch only PHP\u2019s built-in server. Now it spawns a
> dummy FastAPI application too. Since both stacks spawn their own HTTP
> server, aurweb.spawn also spawns nginx as a reverse proxy to mount them
> under the same base URL, defined by aur_location in the configuration.
> ---
>  TESTING              |  3 +-
>  aurweb/asgi.py       |  8 +++++
>  aurweb/spawn.py      | 80 +++++++++++++++++++++++++++++++++++++-------
>  conf/config.defaults |  7 ++++
>  4 files changed, 85 insertions(+), 13 deletions(-)
>  create mode 100644 aurweb/asgi.py
> [...]
> diff --git a/aurweb/asgi.py b/aurweb/asgi.py
> new file mode 100644
> index 00000000..8d3deedc
> --- /dev/null
> +++ b/aurweb/asgi.py
> @@ -0,0 +1,8 @@
> +from fastapi import FastAPI
> +
> +app = FastAPI()
> +
> +
> +@app.get("/sso/")
> +async def hello():
> +    return {"message": "Hello from FastAPI!"}

I know that this is in preparation of the next step, SSO, but can we use
a separate path here, such as /hello/ or /test/? That would cause less
confusion to future readers of this patch (who may not have any context)
and would allow us to decouple the actual SSO patch series from this
patch more easily. For example, that series could start with a patch to
remove this test, and all followup patches would not mention this test
at all.

> diff --git a/conf/config.defaults b/conf/config.defaults
> index 86fe765c..ed495168 100644
> --- a/conf/config.defaults
> +++ b/conf/config.defaults
> @@ -41,9 +41,16 @@ cache = none
>  cache_pkginfo_ttl = 86400
>  memcache_servers = 127.0.0.1:11211
>  
> +[php]
> +; Address PHP should bind when spawned in development mode by aurweb.spawn.
> +bind_address = 127.0.0.1:8081
>  ; Directory containing aurweb's PHP code, required by aurweb.spawn.
>  ;htmldir = /path/to/web/html
>  
> +[fastapi]
> +; Address uvicorn should bind when spawned in development mode by aurweb.spawn.
> +bind_address = 127.0.0.1:8082
> +

Even if the dev-only options are indicated by comments, I am not sure
whether it's a good idea to mix production and development configuration
like this. Can we put them in a separate file or at least a separate
section without too much effort?

Best regards,
Lukas
Frédéric Mangano-Tarumi May 31, 2020, 9:20 p.m. UTC | #3
Lukas Fleischer [2020-05-31 10:16:43 -0400]
> > diff --git a/conf/config.defaults b/conf/config.defaults
> > index 86fe765c..ed495168 100644
> > --- a/conf/config.defaults
> > +++ b/conf/config.defaults
> > @@ -41,9 +41,16 @@ cache = none
> >  cache_pkginfo_ttl = 86400
> >  memcache_servers = 127.0.0.1:11211
> >  
> > +[php]
> > +; Address PHP should bind when spawned in development mode by aurweb.spawn.
> > +bind_address = 127.0.0.1:8081
> >  ; Directory containing aurweb's PHP code, required by aurweb.spawn.
> >  ;htmldir = /path/to/web/html
> >  
> > +[fastapi]
> > +; Address uvicorn should bind when spawned in development mode by aurweb.spawn.
> > +bind_address = 127.0.0.1:8082
> > +
> 
> Even if the dev-only options are indicated by comments, I am not sure
> whether it's a good idea to mix production and development configuration
> like this. Can we put them in a separate file or at least a separate
> section without too much effort?

aurweb.config is quite trivial so we could easily add mechanisms for
handling environments or things like that. However, since these values
are just ignored by production, I don’t think it’s a big deal.

How about creating conf/config.dev with dev-specific options, and
options most developers need to change (disable_http_login, etc.). In
TESTING, we would change 3) to “Copy conf/config.dev to conf/config”.
The downside is that when we add new required options to config.dev,
developers need to update their conf/config manually.

A more powerful option would be to have conf.d/00-defaults.ini,
conf.d/60-dev.ini, and conf.d/90-user.ini. I think most developers are
familiar with conf.d configurations already. Such a switch would break
everyone’s setup though, including production, but the fix is easy.

What do you think?
Lukas Fleischer June 1, 2020, 12:39 a.m. UTC | #4
On Sun, 31 May 2020 at 17:20:45, Frédéric Mangano-Tarumi wrote:
> > Even if the dev-only options are indicated by comments, I am not sure
> > whether it's a good idea to mix production and development configuration
> > like this. Can we put them in a separate file or at least a separate
> > section without too much effort?
> 
> aurweb.config is quite trivial so we could easily add mechanisms for
> handling environments or things like that. However, since these values
> are just ignored by production, I don\u2019t think it\u2019s a big deal.

> How about creating conf/config.dev with dev-specific options, and
> options most developers need to change (disable_http_login, etc.). In
> TESTING, we would change 3) to \u201cCopy conf/config.dev to conf/config\u201d.
> The downside is that when we add new required options to config.dev,
> developers need to update their conf/config manually.

With the current design, you already need to copy the config file to
/etc/aurweb/ on changes or use the AUR_CONFIG environment variable to
point to an alternative path (TESTING uses the latter). So maybe create
a development version of the config file as you suggested, add the
dev-only options only there and update the TESTING instructions to use
that file instead of the production config defaults?

Patch

diff --git a/TESTING b/TESTING
index a5e08cb8..31e3bcbd 100644
--- a/TESTING
+++ b/TESTING
@@ -12,7 +12,8 @@  INSTALL.
 2) Install the necessary packages:
 
     # pacman -S --needed php php-sqlite sqlite words fortune-mod \
-                         python python-sqlalchemy python-alembic
+                         python python-sqlalchemy python-alembic \
+                         python-fastapi uvicorn nginx
 
    Ensure to enable the pdo_sqlite extension in php.ini.
 
diff --git a/aurweb/asgi.py b/aurweb/asgi.py
new file mode 100644
index 00000000..8d3deedc
--- /dev/null
+++ b/aurweb/asgi.py
@@ -0,0 +1,8 @@ 
+from fastapi import FastAPI
+
+app = FastAPI()
+
+
+@app.get("/sso/")
+async def hello():
+    return {"message": "Hello from FastAPI!"}
diff --git a/aurweb/spawn.py b/aurweb/spawn.py
index 5fa646b5..df6a2ed5 100644
--- a/aurweb/spawn.py
+++ b/aurweb/spawn.py
@@ -10,8 +10,10 @@  configuration anyway.
 
 import atexit
 import argparse
+import os
 import subprocess
 import sys
+import tempfile
 import time
 import urllib
 
@@ -20,6 +22,7 @@  import aurweb.schema
 
 
 children = []
+temporary_dir = None
 verbosity = 0
 
 
@@ -35,10 +38,42 @@  class ProcessExceptions(Exception):
         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.
+    """
+    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")};
+                }}
+                location /sso {{
+                    proxy_pass http://{aurweb.config.get("fastapi", "bind_address")};
+                }}
+            }}
+        }}
+    """)
+    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)
+        print(f":: Spawning {args}", file=sys.stderr)
     children.append(subprocess.Popen(args))
 
 
@@ -52,10 +87,29 @@  def start():
     if children:
         return
     atexit.register(stop)
-    aur_location = aurweb.config.get("options", "aur_location")
-    aur_location_parts = urllib.parse.urlsplit(aur_location)
-    htmldir = aurweb.config.get("options", "htmldir")
-    spawn_child(["php", "-S", aur_location_parts.netloc, "-t", htmldir])
+
+    # Friendly message
+    ruler = "-" * os.get_terminal_size().columns
+    print(ruler)
+    print("Spawing PHP and FastAPI, then nginx as a reverse proxy.")
+    print("Check out " + aurweb.config.get("options", "aur_location"))
+    print("Hit ^C to terminate everything.")
+    print(ruler)
+
+    # 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)
+    spawn_child(["python", "-m", "uvicorn",
+                 "--host", host,
+                 "--port", port,
+                 "aurweb.asgi:app"])
+
+    # nginx
+    spawn_child(["nginx", "-p", temporary_dir, "-c", generate_nginx_config()])
 
 
 def stop():
@@ -73,7 +127,7 @@  def stop():
         try:
             p.terminate()
             if verbosity >= 1:
-                print(f"Sent SIGTERM to {p.args}", file=sys.stderr)
+                print(f":: Sent SIGTERM to {p.args}", file=sys.stderr)
         except Exception as e:
             exceptions.append(e)
     for p in children:
@@ -99,9 +153,11 @@  if __name__ == '__main__':
                         help='increase verbosity')
     args = parser.parse_args()
     verbosity = args.verbose
-    start()
-    try:
-        while True:
-            time.sleep(60)
-    except KeyboardInterrupt:
-        stop()
+    with tempfile.TemporaryDirectory(prefix="aurweb-") as tmpdirname:
+        temporary_dir = tmpdirname
+        start()
+        try:
+            while True:
+                time.sleep(60)
+        except KeyboardInterrupt:
+            stop()
diff --git a/conf/config.defaults b/conf/config.defaults
index 86fe765c..ed495168 100644
--- a/conf/config.defaults
+++ b/conf/config.defaults
@@ -41,9 +41,16 @@  cache = none
 cache_pkginfo_ttl = 86400
 memcache_servers = 127.0.0.1:11211
 
+[php]
+; Address PHP should bind when spawned in development mode by aurweb.spawn.
+bind_address = 127.0.0.1:8081
 ; Directory containing aurweb's PHP code, required by aurweb.spawn.
 ;htmldir = /path/to/web/html
 
+[fastapi]
+; Address uvicorn should bind when spawned in development mode by aurweb.spawn.
+bind_address = 127.0.0.1:8082
+
 [ratelimit]
 request_limit = 4000
 window_length = 86400