mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			379 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			379 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
#!/usr/bin/env python3
 | 
						|
import argparse
 | 
						|
import asyncio
 | 
						|
import errno
 | 
						|
import logging
 | 
						|
import os
 | 
						|
import pwd
 | 
						|
import signal
 | 
						|
import subprocess
 | 
						|
import sys
 | 
						|
 | 
						|
TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
 | 
						|
sys.path.insert(0, os.path.dirname(TOOLS_DIR))
 | 
						|
 | 
						|
# check for the venv
 | 
						|
from tools.lib import sanity_check
 | 
						|
 | 
						|
sanity_check.check_venv(__file__)
 | 
						|
 | 
						|
import aiohttp
 | 
						|
from aiohttp import hdrs, web
 | 
						|
from aiohttp.http_exceptions import BadStatusLine
 | 
						|
 | 
						|
from tools.lib.test_script import add_provision_check_override_param, assert_provisioning_status_ok
 | 
						|
from zerver.lib.partial import partial
 | 
						|
 | 
						|
if "posix" in os.name and os.geteuid() == 0:
 | 
						|
    raise RuntimeError("run-dev should not be run as root.")
 | 
						|
 | 
						|
DESCRIPTION = """
 | 
						|
Starts the app listening on localhost, for local development.
 | 
						|
 | 
						|
This script launches the Django and Tornado servers, then runs a reverse proxy
 | 
						|
which serves to both of them.  After it's all up and running, browse to
 | 
						|
 | 
						|
    http://localhost:9991/
 | 
						|
 | 
						|
Note that, while runserver and runtornado have the usual auto-restarting
 | 
						|
behavior, the reverse proxy itself does *not* automatically restart on changes
 | 
						|
to this file.
 | 
						|
"""
 | 
						|
 | 
						|
parser = argparse.ArgumentParser(
 | 
						|
    description=DESCRIPTION, formatter_class=argparse.RawTextHelpFormatter
 | 
						|
)
 | 
						|
 | 
						|
parser.add_argument("--test", action="store_true", help="Use the testing database and ports")
 | 
						|
parser.add_argument("--minify", action="store_true", help="Minifies assets for testing in dev")
 | 
						|
parser.add_argument("--interface", help="Set the IP or hostname for the proxy to listen on")
 | 
						|
parser.add_argument(
 | 
						|
    "--no-clear-memcached",
 | 
						|
    action="store_false",
 | 
						|
    dest="clear_memcached",
 | 
						|
    help="Do not clear memcached on startup",
 | 
						|
)
 | 
						|
parser.add_argument("--streamlined", action="store_true", help="Avoid process_queue, etc.")
 | 
						|
parser.add_argument(
 | 
						|
    "--behind-https-proxy",
 | 
						|
    action="store_true",
 | 
						|
    help="Start app server in HTTPS mode, using reverse proxy",
 | 
						|
)
 | 
						|
add_provision_check_override_param(parser)
 | 
						|
options = parser.parse_args()
 | 
						|
 | 
						|
assert_provisioning_status_ok(options.skip_provision_check)
 | 
						|
 | 
						|
if options.interface is None:
 | 
						|
    user_id = os.getuid()
 | 
						|
    user_name = pwd.getpwuid(user_id).pw_name
 | 
						|
    if user_name in ["vagrant", "zulipdev"]:
 | 
						|
        # In the Vagrant development environment, we need to listen on
 | 
						|
        # all ports, and it's safe to do so, because Vagrant is only
 | 
						|
        # exposing certain guest ports (by default just 9991) to the
 | 
						|
        # host.  The same argument applies to the remote development
 | 
						|
        # servers using username "zulipdev".
 | 
						|
        options.interface = None
 | 
						|
    else:
 | 
						|
        # Otherwise, only listen to requests on localhost for security.
 | 
						|
        options.interface = "127.0.0.1"
 | 
						|
elif options.interface == "":
 | 
						|
    options.interface = None
 | 
						|
 | 
						|
runserver_args: list[str] = []
 | 
						|
base_port = 9991
 | 
						|
if options.test:
 | 
						|
    base_port = 9981
 | 
						|
    settings_module = "zproject.test_settings"
 | 
						|
    # Don't auto-reload when running Puppeteer tests
 | 
						|
    runserver_args = ["--noreload"]
 | 
						|
    runtornado_command = ["./manage.py", "runtornado"]
 | 
						|
else:
 | 
						|
    settings_module = "zproject.settings"
 | 
						|
    runtornado_command = [
 | 
						|
        "-m",
 | 
						|
        "tornado.autoreload",
 | 
						|
        "--until-success",
 | 
						|
        "./manage.py",
 | 
						|
        "runtornado",
 | 
						|
        "--autoreload",
 | 
						|
        "--immediate-reloads",
 | 
						|
    ]
 | 
						|
 | 
						|
manage_args = [f"--settings={settings_module}"]
 | 
						|
os.environ["DJANGO_SETTINGS_MODULE"] = settings_module
 | 
						|
 | 
						|
if options.behind_https_proxy:
 | 
						|
    os.environ["BEHIND_HTTPS_PROXY"] = "1"
 | 
						|
 | 
						|
from scripts.lib.zulip_tools import CYAN, ENDC
 | 
						|
 | 
						|
proxy_port = base_port
 | 
						|
django_port = base_port + 1
 | 
						|
tornado_port = base_port + 2
 | 
						|
webpack_port = base_port + 3
 | 
						|
 | 
						|
os.chdir(os.path.join(os.path.dirname(__file__), ".."))
 | 
						|
 | 
						|
if options.clear_memcached:
 | 
						|
    subprocess.check_call("./scripts/setup/flush-memcached")
 | 
						|
 | 
						|
# Set up a new process group, so that we can later kill run{server,tornado}
 | 
						|
# and all of the processes they spawn.
 | 
						|
os.setpgrp()
 | 
						|
 | 
						|
# Save pid of parent process to the pid file. It can be used later by
 | 
						|
# tools/stop-run-dev to kill the server without having to find the
 | 
						|
# terminal in question.
 | 
						|
 | 
						|
if options.test:
 | 
						|
    pid_file_path = os.path.join(os.path.join(os.getcwd(), "var/puppeteer/run_dev.pid"))
 | 
						|
else:
 | 
						|
    pid_file_path = os.path.join(os.path.join(os.getcwd(), "var/run/run_dev.pid"))
 | 
						|
 | 
						|
# Required for compatibility python versions.
 | 
						|
if not os.path.exists(os.path.dirname(pid_file_path)):
 | 
						|
    os.makedirs(os.path.dirname(pid_file_path))
 | 
						|
with open(pid_file_path, "w+") as f:
 | 
						|
    f.write(str(os.getpgrp()) + "\n")
 | 
						|
 | 
						|
 | 
						|
def server_processes() -> list[list[str]]:
 | 
						|
    main_cmds = [
 | 
						|
        [
 | 
						|
            "./manage.py",
 | 
						|
            "rundjangoserver",
 | 
						|
            *manage_args,
 | 
						|
            *runserver_args,
 | 
						|
            f"127.0.0.1:{django_port}",
 | 
						|
        ],
 | 
						|
        [
 | 
						|
            "env",
 | 
						|
            "PYTHONUNBUFFERED=1",
 | 
						|
            "python3",
 | 
						|
            *runtornado_command,
 | 
						|
            *manage_args,
 | 
						|
            f"127.0.0.1:{tornado_port}",
 | 
						|
        ],
 | 
						|
    ]
 | 
						|
 | 
						|
    if options.streamlined:
 | 
						|
        # The streamlined operation allows us to do many
 | 
						|
        # things, but search/etc. features won't work.
 | 
						|
        return main_cmds
 | 
						|
 | 
						|
    other_cmds = [
 | 
						|
        ["./manage.py", "process_queue", "--all", *manage_args],
 | 
						|
        [
 | 
						|
            "env",
 | 
						|
            "PGHOST=127.0.0.1",  # Force password authentication using .pgpass
 | 
						|
            "./puppet/zulip/files/postgresql/process_fts_updates",
 | 
						|
            "--quiet",
 | 
						|
        ],
 | 
						|
        ["./manage.py", "deliver_scheduled_messages"],
 | 
						|
    ]
 | 
						|
 | 
						|
    # NORMAL (but slower) operation:
 | 
						|
    return main_cmds + other_cmds
 | 
						|
 | 
						|
 | 
						|
def do_one_time_webpack_compile() -> None:
 | 
						|
    # We just need to compile webpack assets once at startup, not run a daemon,
 | 
						|
    # in test mode.  Additionally, webpack-dev-server doesn't support running 2
 | 
						|
    # copies on the same system, so this model lets us run the Puppeteer tests
 | 
						|
    # with a running development server.
 | 
						|
    subprocess.check_call(["./tools/webpack", "--quiet", "--test"])
 | 
						|
 | 
						|
 | 
						|
def start_webpack_watcher() -> "subprocess.Popen[bytes]":
 | 
						|
    webpack_cmd = ["./tools/webpack", "--watch", f"--port={webpack_port}"]
 | 
						|
    if options.minify:
 | 
						|
        webpack_cmd.append("--minify")
 | 
						|
    if options.interface is None:
 | 
						|
        # If interface is None and we're listening on all ports, we also need
 | 
						|
        # to disable the webpack host check so that webpack will serve assets.
 | 
						|
        webpack_cmd.append("--disable-host-check")
 | 
						|
    if options.interface:
 | 
						|
        webpack_cmd.append(f"--host={options.interface}")
 | 
						|
    else:
 | 
						|
        webpack_cmd.append("--host=0.0.0.0")
 | 
						|
    return subprocess.Popen(webpack_cmd)
 | 
						|
 | 
						|
 | 
						|
session: aiohttp.ClientSession
 | 
						|
 | 
						|
# https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1
 | 
						|
HOP_BY_HOP_HEADERS = {
 | 
						|
    hdrs.CONNECTION,
 | 
						|
    hdrs.KEEP_ALIVE,
 | 
						|
    hdrs.PROXY_AUTHENTICATE,
 | 
						|
    hdrs.PROXY_AUTHORIZATION,
 | 
						|
    hdrs.TE,
 | 
						|
    hdrs.TRAILER,
 | 
						|
    hdrs.TRANSFER_ENCODING,
 | 
						|
    hdrs.UPGRADE,
 | 
						|
}
 | 
						|
 | 
						|
# Headers that aiohttp would otherwise generate by default
 | 
						|
SKIP_AUTO_HEADERS = {
 | 
						|
    hdrs.ACCEPT,
 | 
						|
    hdrs.ACCEPT_ENCODING,
 | 
						|
    hdrs.CONTENT_TYPE,
 | 
						|
    hdrs.USER_AGENT,
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
async def forward(upstream_port: int, request: web.Request) -> web.StreamResponse:
 | 
						|
    try:
 | 
						|
        upstream_response = await session.request(
 | 
						|
            request.method,
 | 
						|
            request.url.with_host("127.0.0.1").with_port(upstream_port),
 | 
						|
            headers=[
 | 
						|
                (key, value)
 | 
						|
                for key, value in request.headers.items()
 | 
						|
                if key not in HOP_BY_HOP_HEADERS
 | 
						|
            ],
 | 
						|
            data=request.content.iter_any() if request.body_exists else None,
 | 
						|
            allow_redirects=False,
 | 
						|
            auto_decompress=False,
 | 
						|
            compress=False,
 | 
						|
            skip_auto_headers=SKIP_AUTO_HEADERS,
 | 
						|
        )
 | 
						|
    except aiohttp.ClientError as error:
 | 
						|
        logging.error(
 | 
						|
            "Failed to forward %s %s to port %d: %s",
 | 
						|
            request.method,
 | 
						|
            request.url.path,
 | 
						|
            upstream_port,
 | 
						|
            error,
 | 
						|
        )
 | 
						|
        raise web.HTTPBadGateway from error
 | 
						|
 | 
						|
    response = web.StreamResponse(status=upstream_response.status, reason=upstream_response.reason)
 | 
						|
    response.headers.extend(
 | 
						|
        (key, value)
 | 
						|
        for key, value in upstream_response.headers.items()
 | 
						|
        if key not in HOP_BY_HOP_HEADERS
 | 
						|
    )
 | 
						|
    assert request.remote is not None
 | 
						|
    response.headers["X-Real-IP"] = request.remote
 | 
						|
    response.headers["X-Forwarded-Port"] = str(proxy_port)
 | 
						|
    await response.prepare(request)
 | 
						|
    async for data in upstream_response.content.iter_any():
 | 
						|
        await response.write(data)
 | 
						|
    await response.write_eof()
 | 
						|
    return response
 | 
						|
 | 
						|
 | 
						|
app = web.Application()
 | 
						|
app.add_routes(
 | 
						|
    [
 | 
						|
        web.route(
 | 
						|
            hdrs.METH_ANY, r"/{path:json/events|api/v1/events}", partial(forward, tornado_port)
 | 
						|
        ),
 | 
						|
        web.route(hdrs.METH_ANY, r"/{path:webpack/.*}", partial(forward, webpack_port)),
 | 
						|
        web.route(hdrs.METH_ANY, r"/{path:.*}", partial(forward, django_port)),
 | 
						|
    ]
 | 
						|
)
 | 
						|
 | 
						|
 | 
						|
def print_listeners() -> None:
 | 
						|
    # Since we can't import settings from here, we duplicate some
 | 
						|
    # EXTERNAL_HOST logic from dev_settings.py.
 | 
						|
    IS_DEV_DROPLET = pwd.getpwuid(os.getuid()).pw_name == "zulipdev"
 | 
						|
    if IS_DEV_DROPLET:
 | 
						|
        # Technically, the `zulip.` is a subdomain of the server, so
 | 
						|
        # this is kinda misleading, but 99% of development is done on
 | 
						|
        # the default/zulip subdomain.
 | 
						|
        default_hostname = "zulip." + os.uname()[1].lower()
 | 
						|
    else:
 | 
						|
        default_hostname = "localhost"
 | 
						|
    external_host = os.getenv("EXTERNAL_HOST", f"{default_hostname}:{proxy_port}")
 | 
						|
    http_protocol = "https" if options.behind_https_proxy else "http"
 | 
						|
    print(
 | 
						|
        f"\nStarting Zulip on:\n\n\t{CYAN}{http_protocol}://{external_host}/{ENDC}\n\nInternal ports:"
 | 
						|
    )
 | 
						|
    ports = [
 | 
						|
        (proxy_port, "Development server proxy (connect here)"),
 | 
						|
        (django_port, "Django"),
 | 
						|
        (tornado_port, "Tornado"),
 | 
						|
    ]
 | 
						|
 | 
						|
    if not options.test:
 | 
						|
        ports.append((webpack_port, "webpack"))
 | 
						|
 | 
						|
    for port, label in ports:
 | 
						|
        print(f"   {port}: {label}")
 | 
						|
    print()
 | 
						|
 | 
						|
 | 
						|
def https_log_filter(record: logging.LogRecord) -> bool:
 | 
						|
    # aiohttp emits an exception with a traceback when receiving an
 | 
						|
    # https request (https://github.com/aio-libs/aiohttp/issues/8065).
 | 
						|
    # Abbreviate it to a one-line message.
 | 
						|
    if (
 | 
						|
        record.exc_info is not None
 | 
						|
        and isinstance(error := record.exc_info[1], BadStatusLine)
 | 
						|
        and error.message.startswith(
 | 
						|
            (
 | 
						|
                "Invalid method encountered:\n\n  b'\\x16",
 | 
						|
                'Invalid method encountered:\n\n  b"\\x16',
 | 
						|
            )
 | 
						|
        )
 | 
						|
    ):
 | 
						|
        record.msg = "Rejected https request (this development server only supports http)"
 | 
						|
        record.exc_info = None
 | 
						|
    return True
 | 
						|
 | 
						|
 | 
						|
logging.getLogger("aiohttp.server").addFilter(https_log_filter)
 | 
						|
 | 
						|
runner: web.AppRunner
 | 
						|
children: list["subprocess.Popen[bytes]"] = []
 | 
						|
 | 
						|
 | 
						|
async def serve() -> None:
 | 
						|
    global runner, session
 | 
						|
 | 
						|
    if options.test:
 | 
						|
        do_one_time_webpack_compile()
 | 
						|
    else:
 | 
						|
        children.append(start_webpack_watcher())
 | 
						|
 | 
						|
    children.extend(subprocess.Popen(cmd) for cmd in server_processes())
 | 
						|
 | 
						|
    session = aiohttp.ClientSession()
 | 
						|
    runner = web.AppRunner(app, auto_decompress=False, handler_cancellation=True)
 | 
						|
    await runner.setup()
 | 
						|
    site = web.TCPSite(runner, host=options.interface, port=proxy_port)
 | 
						|
    try:
 | 
						|
        await site.start()
 | 
						|
    except OSError as e:
 | 
						|
        if e.errno == errno.EADDRINUSE:
 | 
						|
            print("\n\nERROR: You probably have another server running!!!\n\n")
 | 
						|
        raise
 | 
						|
 | 
						|
    print_listeners()
 | 
						|
 | 
						|
 | 
						|
loop = asyncio.new_event_loop()
 | 
						|
 | 
						|
try:
 | 
						|
    loop.run_until_complete(serve())
 | 
						|
    for s in (signal.SIGINT, signal.SIGTERM):
 | 
						|
        loop.add_signal_handler(s, loop.stop)
 | 
						|
    loop.run_forever()
 | 
						|
finally:
 | 
						|
    loop.run_until_complete(runner.cleanup())
 | 
						|
    loop.run_until_complete(session.close())
 | 
						|
 | 
						|
    for child in children:
 | 
						|
        child.terminate()
 | 
						|
 | 
						|
    print("Waiting for children to stop...")
 | 
						|
    for child in children:
 | 
						|
        child.wait()
 | 
						|
 | 
						|
    # Remove pid file when development server closed correctly.
 | 
						|
    os.remove(pid_file_path)
 |