mirror of
https://github.com/zulip/zulip.git
synced 2025-10-30 11:33:51 +00:00
For basic testing (either manual or automated), we
generally only need the server and tornado running.
Obviously, it's nice to test the complete system,
but if you're on a slow PC, the overhead can be
annoying.
Note that we don't launch any of these processes
in `--streamlined` mode:
process_queue
process_fts_updates
deliver_scheduled_messages
thumbor
And then by not launching process_queue, we avoid
several child processes.
Basic functionality like sending messages will
still work here.
The streamlined mode may be helpful in debugging
our generally slow server startup time. Obviously,
some of the problem with startup is the auxiliary
processes here, but removing them as a variable
could help us focus on getting the core stuff fast.
Note that we still have the webpack watcher running
in streamlined mode.
For the particular case of thumbor, note that we
modify the proxy server to explicitly print and
return an error if we get a `/thumbor/*` request.
405 lines
13 KiB
Python
Executable File
405 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import os
|
|
import pwd
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import traceback
|
|
|
|
from urllib.parse import urlunparse
|
|
|
|
# check for the venv
|
|
from lib import sanity_check
|
|
sanity_check.check_venv(__file__)
|
|
|
|
from tornado import httpclient
|
|
from tornado import httputil
|
|
from tornado import gen
|
|
from tornado import web
|
|
from tornado.ioloop import IOLoop
|
|
|
|
TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(TOOLS_DIR))
|
|
from tools.lib.test_script import (
|
|
assert_provisioning_status_ok,
|
|
)
|
|
|
|
from typing import Any, Callable, Generator, List, Optional
|
|
|
|
if 'posix' in os.name and os.geteuid() == 0:
|
|
raise RuntimeError("run-dev.py 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',
|
|
action='store',
|
|
default=None, help='Set the IP or hostname for the proxy to listen on')
|
|
parser.add_argument('--no-clear-memcached',
|
|
action='store_false', dest='clear_memcached',
|
|
default=True, help='Do not clear memcached')
|
|
parser.add_argument('--streamlined',
|
|
action="store_true",
|
|
default=False, help='Avoid thumbor, etc.')
|
|
parser.add_argument('--force',
|
|
action="store_true",
|
|
default=False, help='Run command despite possible problems.')
|
|
parser.add_argument('--enable-tornado-logging',
|
|
action="store_true",
|
|
default=False, help='Enable access logs from tornado proxy server.')
|
|
options = parser.parse_args()
|
|
|
|
assert_provisioning_status_ok(options.force)
|
|
|
|
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 casper tests
|
|
runserver_args = ['--noreload']
|
|
else:
|
|
settings_module = "zproject.settings"
|
|
|
|
manage_args = ['--settings=%s' % (settings_module,)]
|
|
os.environ['DJANGO_SETTINGS_MODULE'] = settings_module
|
|
|
|
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
from scripts.lib.zulip_tools import CYAN, WARNING, FAIL, ENDC
|
|
|
|
proxy_port = base_port
|
|
django_port = base_port + 1
|
|
tornado_port = base_port + 2
|
|
webpack_port = base_port + 3
|
|
thumbor_port = base_port + 4
|
|
|
|
os.chdir(os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
# Clean up stale .pyc files etc.
|
|
subprocess.check_call('./tools/clean-repo')
|
|
|
|
if options.clear_memcached:
|
|
print("Clearing 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/casper/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', 'runserver'] +
|
|
manage_args + runserver_args + ['127.0.0.1:%d' % (django_port,)],
|
|
['env', 'PYTHONUNBUFFERED=1', './manage.py', 'runtornado'] +
|
|
manage_args + ['127.0.0.1:%d' % (tornado_port,)],
|
|
]
|
|
|
|
if options.streamlined:
|
|
# The streamlined operation allows us to do many
|
|
# things, but search/thumbor/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'],
|
|
['./manage.py', 'deliver_scheduled_messages'],
|
|
['/srv/zulip-thumbor-venv/bin/thumbor', '-c', './zthumbor/thumbor.conf',
|
|
'-p', '%s' % (thumbor_port,)],
|
|
]
|
|
|
|
# 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 casper tests
|
|
# with a running development server.
|
|
subprocess.check_call(['./tools/webpack', '--quiet', '--test'])
|
|
|
|
def start_webpack_watcher() -> None:
|
|
webpack_cmd = ['./tools/webpack', '--watch', '--port', str(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 += ["--host", options.interface]
|
|
else:
|
|
webpack_cmd += ["--host", "0.0.0.0"]
|
|
subprocess.Popen(webpack_cmd)
|
|
|
|
def transform_url(protocol: str, path: str, query: str, target_port: int, target_host: str) -> str:
|
|
# generate url with target host
|
|
host = ":".join((target_host, str(target_port)))
|
|
# Here we are going to rewrite the path a bit so that it is in parity with
|
|
# what we will have for production
|
|
if path.startswith('/thumbor'):
|
|
path = path[len('/thumbor'):]
|
|
newpath = urlunparse((protocol, host, path, '', query, ''))
|
|
return newpath
|
|
|
|
@gen.engine
|
|
def fetch_request(url: str, callback: Any, **kwargs: Any) -> "Generator[Callable[..., Any], Any, None]":
|
|
# use large timeouts to handle polling requests
|
|
req = httpclient.HTTPRequest(
|
|
url,
|
|
connect_timeout=240.0,
|
|
request_timeout=240.0,
|
|
decompress_response=False,
|
|
**kwargs
|
|
)
|
|
client = httpclient.AsyncHTTPClient()
|
|
# wait for response
|
|
response = yield gen.Task(client.fetch, req)
|
|
callback(response)
|
|
|
|
|
|
class BaseHandler(web.RequestHandler):
|
|
# target server ip
|
|
target_host: str = '127.0.0.1'
|
|
# target server port
|
|
target_port: int
|
|
|
|
def _add_request_headers(
|
|
self, exclude_lower_headers_list: Optional[List[str]] = None
|
|
) -> httputil.HTTPHeaders:
|
|
exclude_lower_headers_list = exclude_lower_headers_list or []
|
|
headers = httputil.HTTPHeaders()
|
|
for header, v in self.request.headers.get_all():
|
|
if header.lower() not in exclude_lower_headers_list:
|
|
headers.add(header, v)
|
|
return headers
|
|
|
|
def get(self) -> None:
|
|
pass
|
|
|
|
def head(self) -> None:
|
|
pass
|
|
|
|
def post(self) -> None:
|
|
pass
|
|
|
|
def put(self) -> None:
|
|
pass
|
|
|
|
def patch(self) -> None:
|
|
pass
|
|
|
|
def options(self) -> None:
|
|
pass
|
|
|
|
def delete(self) -> None:
|
|
pass
|
|
|
|
def handle_response(self, response: Any) -> None:
|
|
if response.error and not isinstance(response.error, httpclient.HTTPError):
|
|
self.set_status(500)
|
|
self.write('Internal server error:\n' + str(response.error))
|
|
else:
|
|
self.set_status(response.code, response.reason)
|
|
self._headers = httputil.HTTPHeaders() # clear tornado default header
|
|
|
|
for header, v in response.headers.get_all():
|
|
# some header appear multiple times, eg 'Set-Cookie'
|
|
self.add_header(header, v)
|
|
if response.body:
|
|
self.write(response.body)
|
|
self.finish()
|
|
|
|
@web.asynchronous
|
|
def prepare(self) -> None:
|
|
if 'X-REAL-IP' not in self.request.headers:
|
|
self.request.headers['X-REAL-IP'] = self.request.remote_ip
|
|
if 'X-FORWARDED_PORT' not in self.request.headers:
|
|
self.request.headers['X-FORWARDED-PORT'] = str(proxy_port)
|
|
url = transform_url(
|
|
self.request.protocol,
|
|
self.request.path,
|
|
self.request.query,
|
|
self.target_port,
|
|
self.target_host,
|
|
)
|
|
try:
|
|
fetch_request(
|
|
url=url,
|
|
callback=self.handle_response,
|
|
method=self.request.method,
|
|
headers=self._add_request_headers(["upgrade-insecure-requests"]),
|
|
follow_redirects=False,
|
|
body=getattr(self.request, 'body'),
|
|
allow_nonstandard_methods=True
|
|
)
|
|
except httpclient.HTTPError as e:
|
|
if hasattr(e, 'response') and e.response:
|
|
self.handle_response(e.response)
|
|
else:
|
|
self.set_status(500)
|
|
self.write('Internal server error:\n' + str(e))
|
|
self.finish()
|
|
|
|
|
|
class WebPackHandler(BaseHandler):
|
|
target_port = webpack_port
|
|
|
|
|
|
class DjangoHandler(BaseHandler):
|
|
target_port = django_port
|
|
|
|
|
|
class TornadoHandler(BaseHandler):
|
|
target_port = tornado_port
|
|
|
|
|
|
class ThumborHandler(BaseHandler):
|
|
target_port = thumbor_port
|
|
|
|
|
|
class ErrorHandler(BaseHandler):
|
|
@web.asynchronous
|
|
def prepare(self) -> None:
|
|
print(FAIL + 'Unexpected request: ' + ENDC, self.request.path)
|
|
self.set_status(500)
|
|
self.write('path not supported')
|
|
self.finish()
|
|
|
|
def using_thumbor() -> bool:
|
|
return not options.streamlined
|
|
|
|
class Application(web.Application):
|
|
def __init__(self, enable_logging: bool = False) -> None:
|
|
handlers = [
|
|
(r"/json/events.*", TornadoHandler),
|
|
(r"/api/v1/events.*", TornadoHandler),
|
|
(r"/webpack.*", WebPackHandler),
|
|
(r"/thumbor.*", ThumborHandler if using_thumbor() else ErrorHandler),
|
|
(r"/.*", DjangoHandler)
|
|
]
|
|
super().__init__(handlers, enable_logging=enable_logging)
|
|
|
|
def log_request(self, handler: BaseHandler) -> None:
|
|
if self.settings['enable_logging']:
|
|
super().log_request(handler)
|
|
|
|
|
|
def on_shutdown() -> None:
|
|
IOLoop.instance().stop()
|
|
|
|
|
|
def shutdown_handler(*args: Any, **kwargs: Any) -> None:
|
|
io_loop = IOLoop.instance()
|
|
if io_loop._callbacks:
|
|
io_loop.call_later(1, shutdown_handler)
|
|
else:
|
|
io_loop.stop()
|
|
|
|
def print_listeners() -> None:
|
|
print("\nZulip services will listen on ports:")
|
|
ports = [
|
|
(proxy_port, CYAN + 'web proxy' + ENDC),
|
|
(django_port, 'Django'),
|
|
(tornado_port, 'Tornado'),
|
|
]
|
|
|
|
if not options.test:
|
|
ports.append((webpack_port, 'webpack'))
|
|
|
|
if using_thumbor():
|
|
ports.append((thumbor_port, 'Thumbor'))
|
|
|
|
for port, label in ports:
|
|
print(f' {port}: {label}')
|
|
print()
|
|
|
|
proxy_warning = f"Only the proxy port ({proxy_port}) is exposed."
|
|
print(WARNING + "Note to Vagrant users: " + ENDC + proxy_warning + '\n')
|
|
|
|
if options.test:
|
|
do_one_time_webpack_compile()
|
|
else:
|
|
start_webpack_watcher()
|
|
|
|
for cmd in server_processes():
|
|
subprocess.Popen(cmd)
|
|
|
|
try:
|
|
app = Application(enable_logging=options.enable_tornado_logging)
|
|
try:
|
|
app.listen(proxy_port, address=options.interface)
|
|
except OSError as e:
|
|
if e.errno == 98:
|
|
print('\n\nERROR: You probably have another server running!!!\n\n')
|
|
raise
|
|
|
|
print_listeners()
|
|
|
|
ioloop = IOLoop.instance()
|
|
for s in (signal.SIGINT, signal.SIGTERM):
|
|
signal.signal(s, shutdown_handler)
|
|
ioloop.start()
|
|
except Exception:
|
|
# Print the traceback before we get SIGTERM and die.
|
|
traceback.print_exc()
|
|
raise
|
|
finally:
|
|
# Kill everything in our process group.
|
|
os.killpg(0, signal.SIGTERM)
|
|
# Remove pid file when development server closed correctly.
|
|
os.remove(pid_file_path)
|