mirror of https://github.com/zulip/zulip.git
448 lines
14 KiB
Python
Executable File
448 lines
14 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
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
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",
|
|
)
|
|
parser.add_argument(
|
|
"--help-center", action="store_true", help="Build and host help center with search"
|
|
)
|
|
parser.add_argument(
|
|
"--help-center-dev-server",
|
|
action="store_true",
|
|
help="Run dev server for help center. Hot reload will work for this mode, but search will not work in the generated website.",
|
|
)
|
|
add_provision_check_override_param(parser)
|
|
options = parser.parse_args()
|
|
help_center_dev_server_enabled = options.help_center_dev_server and not options.help_center
|
|
|
|
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
|
|
help_center_port = base_port + 4
|
|
|
|
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,
|
|
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
|
|
|
|
|
|
def run_help_center_dev_server() -> "subprocess.Popen[bytes]":
|
|
return subprocess.Popen(
|
|
["/usr/local/bin/corepack", "pnpm", "dev", f"--port={help_center_port}", "--host"],
|
|
cwd="help-beta",
|
|
)
|
|
|
|
|
|
@web.middleware
|
|
async def help_center_middleware(
|
|
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
|
) -> web.StreamResponse:
|
|
if request.path.startswith("/help-beta"):
|
|
try:
|
|
filename = request.match_info["filename"]
|
|
name, ext = os.path.splitext(filename)
|
|
if not ext:
|
|
filename = os.path.join(filename, "index.html")
|
|
request.match_info["filename"] = filename
|
|
except KeyError:
|
|
pass
|
|
|
|
return await handler(request)
|
|
|
|
|
|
middlewares = []
|
|
if options.help_center:
|
|
middlewares.append(help_center_middleware)
|
|
|
|
app = web.Application(middlewares=middlewares)
|
|
|
|
|
|
def setup_routes(
|
|
enable_help_center: bool = False, enable_help_center_dev_server: bool = False
|
|
) -> None:
|
|
if enable_help_center:
|
|
# Order of adding the rules matters. aiohttp will stop at the first
|
|
# match, and we want `/help-beta` to be matched before Django URIs.
|
|
try:
|
|
app.router.add_static("/help-beta", "help-beta/dist")
|
|
except ValueError:
|
|
print("""Please run the build step for the help center before enabling it.
|
|
The instructions for the build step can be found in the `./devtools`
|
|
page. `/help-beta` urls will give you an error until you complete the
|
|
build step and rerun `run-dev`.""")
|
|
elif enable_help_center_dev_server:
|
|
app.add_routes(
|
|
[
|
|
web.route(
|
|
hdrs.METH_ANY, r"/{path:(help-beta).*}", partial(forward, help_center_port)
|
|
),
|
|
]
|
|
)
|
|
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"))
|
|
|
|
if help_center_dev_server_enabled:
|
|
ports.append((help_center_port, "Help center - Astro dev server"))
|
|
|
|
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())
|
|
|
|
if help_center_dev_server_enabled:
|
|
children.append(run_help_center_dev_server())
|
|
|
|
setup_routes(options.help_center, options.help_center_dev_server)
|
|
|
|
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)
|