You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
680 lines
24 KiB
680 lines
24 KiB
if __name__ == '__main__':
|
|
import sys
|
|
print("Critical - GAD must be started as a python module, for example using python -m gitautodeploy")
|
|
sys.exit()
|
|
|
|
|
|
class LogInterface(object):
|
|
"""Interface that functions as a stdout and stderr handler and directs the
|
|
output to the logging module, which in turn will output to either console,
|
|
file or both."""
|
|
|
|
def __init__(self, level=None):
|
|
import logging
|
|
self.level = (level if level else logging.getLogger().info)
|
|
|
|
def write(self, msg):
|
|
for line in msg.strip().split("\n"):
|
|
self.level(line)
|
|
|
|
def flush(self):
|
|
pass
|
|
|
|
|
|
from .wsserver import WebSocketClientHandlerFactory
|
|
from .httpserver import WebhookRequestHandlerFactory
|
|
|
|
|
|
class GitAutoDeploy(object):
|
|
_instance = None
|
|
_http_server = None
|
|
_https_server = None
|
|
_https_server_unwrapped_socket = None
|
|
_config = {}
|
|
_server_status = {}
|
|
_pid = None
|
|
_event_store = None
|
|
_default_stdout = None
|
|
_default_stderr = None
|
|
_startup_event = None
|
|
_ws_clients = []
|
|
_http_port = None
|
|
|
|
def __new__(cls, *args, **kwargs):
|
|
"""Overload constructor to enable singleton access"""
|
|
if not cls._instance:
|
|
cls._instance = super(GitAutoDeploy, cls).__new__(
|
|
cls, *args, **kwargs)
|
|
return cls._instance
|
|
|
|
def __init__(self):
|
|
from .events import EventStore, StartupEvent
|
|
|
|
# Setup an event store instance that can keep a global record of events
|
|
self._event_store = EventStore()
|
|
self._event_store.register_observer(self)
|
|
|
|
# Create a startup event that can hold status and any error messages
|
|
# from the startup process
|
|
self._startup_event = StartupEvent()
|
|
self._event_store.register_action(self._startup_event)
|
|
|
|
def clone_all_repos(self):
|
|
"""Iterates over all configured repositories and clones them to their
|
|
configured paths."""
|
|
import os
|
|
import re
|
|
import logging
|
|
from .wrappers import GitWrapper
|
|
logger = logging.getLogger()
|
|
|
|
if 'repositories' not in self._config:
|
|
return
|
|
|
|
# Iterate over all configured repositories
|
|
for repo_config in self._config['repositories']:
|
|
|
|
# Only clone repositories with a configured path
|
|
if 'url' not in repo_config:
|
|
logger.critical("Repository has no configured URL")
|
|
self.exit()
|
|
return
|
|
|
|
# Only clone repositories with a configured path
|
|
if 'path' not in repo_config:
|
|
logger.debug("Repository %s will not be cloned (no path configured)" % repo_config['url'])
|
|
continue
|
|
|
|
if os.path.isdir(repo_config['path']) and os.path.isdir(repo_config['path']+'/.git'):
|
|
GitWrapper.init(repo_config)
|
|
else:
|
|
GitWrapper.clone(repo_config)
|
|
|
|
def ssh_key_scan(self):
|
|
import re
|
|
import logging
|
|
from .wrappers import ProcessWrapper
|
|
logger = logging.getLogger()
|
|
|
|
for repository in self._config['repositories']:
|
|
|
|
if 'url' not in repository:
|
|
continue
|
|
|
|
logger.info("Scanning repository: %s" % repository['url'])
|
|
m = re.match('[^\@]+\@([^\:\/]+)(:(\d+))?', repository['url'])
|
|
|
|
if m is not None:
|
|
host = m.group(1)
|
|
port = m.group(3)
|
|
port_arg = '' if port is None else ('-p %s ' % port)
|
|
cmd = 'ssh-keyscan %s%s >> $HOME/.ssh/known_hosts' % (port_arg, host)
|
|
ProcessWrapper().call([cmd], shell=True)
|
|
|
|
else:
|
|
logger.error('Could not find regexp match in path: %s' % repository['url'])
|
|
|
|
def create_pid_file(self):
|
|
import os
|
|
|
|
with open(self._config['pid-file'], 'w') as f:
|
|
f.write(str(os.getpid()))
|
|
|
|
def read_pid_file(self):
|
|
with open(self._config['pid-file'], 'r') as f:
|
|
return f.readlines()
|
|
|
|
def remove_pid_file(self):
|
|
import os
|
|
import errno
|
|
if 'pid-file' in self._config and self._config['pid-file']:
|
|
try:
|
|
os.remove(self._config['pid-file'])
|
|
except OSError as e:
|
|
# errno.ENOENT = no such file or directory
|
|
if e.errno != errno.ENOENT:
|
|
raise
|
|
|
|
@staticmethod
|
|
def create_daemon():
|
|
import os
|
|
|
|
try:
|
|
# Spawn first child. Returns 0 in the child and pid in the parent.
|
|
pid = os.fork()
|
|
except OSError as e:
|
|
raise Exception("%s [%d]" % (e.strerror, e.errno))
|
|
|
|
# First child
|
|
if pid == 0:
|
|
os.setsid()
|
|
|
|
try:
|
|
# Spawn second child
|
|
pid = os.fork()
|
|
|
|
except OSError as e:
|
|
raise Exception("%s [%d]" % (e.strerror, e.errno))
|
|
|
|
if pid == 0:
|
|
os.umask(0)
|
|
else:
|
|
# Kill first child
|
|
os._exit(0)
|
|
else:
|
|
# Kill parent of first child
|
|
os._exit(0)
|
|
|
|
return 0
|
|
|
|
def update(self, *args, **kwargs):
|
|
import json
|
|
data = json.dumps(kwargs).encode('utf-8')
|
|
for client in self._ws_clients:
|
|
client.sendMessage(data)
|
|
|
|
def get_log_formatter(self):
|
|
import logging
|
|
return logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s")
|
|
|
|
def setup_console_logger(self):
|
|
import logging
|
|
|
|
# Set up logging
|
|
logger = logging.getLogger()
|
|
|
|
consoleHandler = logging.StreamHandler()
|
|
consoleHandler.setFormatter(self.get_log_formatter())
|
|
|
|
# Check if a stream handler is already present (will be if GAD is started by test script)
|
|
handler_present = False
|
|
for handler in logger.handlers:
|
|
if isinstance(handler, type(consoleHandler)):
|
|
handler_present = True
|
|
break
|
|
|
|
if not handler_present:
|
|
logger.addHandler(consoleHandler)
|
|
|
|
def setup(self, config):
|
|
"""Setup an instance of GAD based on the provided config object."""
|
|
import sys
|
|
import socket
|
|
import os
|
|
import logging
|
|
import base64
|
|
from .lock import Lock
|
|
import getpass
|
|
|
|
# This solves https://github.com/olipo186/Git-Auto-Deploy/issues/118
|
|
try:
|
|
from logging import NullHandler
|
|
except ImportError:
|
|
from logging import Handler
|
|
|
|
class NullHandler(Handler):
|
|
def emit(self, record):
|
|
pass
|
|
|
|
# Attatch config values to this instance
|
|
self._config = config
|
|
|
|
# Set up logging
|
|
logger = logging.getLogger()
|
|
logFormatter = self.get_log_formatter()
|
|
|
|
# Enable console output?
|
|
if ('quiet' in self._config and self._config['quiet']) or ('daemon-mode' in self._config and self._config['daemon-mode']):
|
|
|
|
# Add a default null handler that suppresses any console output
|
|
logger.addHandler(NullHandler())
|
|
|
|
else:
|
|
|
|
# Set up console logger if not already present
|
|
self.setup_console_logger()
|
|
|
|
# Set logging level
|
|
if 'log-level' in self._config:
|
|
level = logging.getLevelName(self._config['log-level'])
|
|
logger.setLevel(level)
|
|
|
|
if 'log-file' in self._config and self._config['log-file']:
|
|
# Translate any ~ in the path into /home/<user>
|
|
fileHandler = logging.FileHandler(self._config['log-file'])
|
|
fileHandler.setFormatter(logFormatter)
|
|
logger.addHandler(fileHandler)
|
|
|
|
# Display a warning when trying to run as root
|
|
if not self._config['allow-root-user'] and getpass.getuser() == 'root':
|
|
logger.critical("Refusing to start as root. This application shouldn't run as a privileged used. Please run it as a different user. To disregard this warning and start anyway, set the config option \"allow-root-user\" to true, or use the command line argument --allow-root-user")
|
|
sys.exit()
|
|
|
|
if 'ssh-keyscan' in self._config and self._config['ssh-keyscan']:
|
|
self._startup_event.log_info('Scanning repository hosts for ssh keys...')
|
|
self.ssh_key_scan()
|
|
|
|
# Clone all repos once initially
|
|
self.clone_all_repos()
|
|
|
|
# Set default stdout and stderr to our logging interface (that writes
|
|
# to file and console depending on user preference)
|
|
if 'intercept-stdout' in self._config and self._config['intercept-stdout']:
|
|
self._default_stdout = sys.stdout
|
|
self._default_stderr = sys.stderr
|
|
sys.stdout = LogInterface(logger.info)
|
|
sys.stderr = LogInterface(logger.error)
|
|
|
|
if 'daemon-mode' in self._config and self._config['daemon-mode']:
|
|
self._startup_event.log_info('Starting Git Auto Deploy in daemon mode')
|
|
GitAutoDeploy.create_daemon()
|
|
|
|
self._pid = os.getpid()
|
|
self.create_pid_file()
|
|
|
|
# Generate auth key to protect the web socket server
|
|
self._server_status['auth-key'] = base64.b64encode(os.urandom(32))
|
|
|
|
# Clear any existing lock files, with no regard to possible ongoing processes
|
|
for repo_config in self._config['repositories']:
|
|
|
|
# Do we have a physical repository?
|
|
if 'path' in repo_config:
|
|
Lock(os.path.join(repo_config['path'], 'status_running')).clear()
|
|
Lock(os.path.join(repo_config['path'], 'status_waiting')).clear()
|
|
|
|
#if 'daemon-mode' not in self._config or not self._config['daemon-mode']:
|
|
# self._startup_event.log_info('Git Auto Deploy started')
|
|
|
|
def serve_http(self, serve_forever=True):
|
|
"""Starts a HTTP server that listens for webhook requests and serves the web ui."""
|
|
import sys
|
|
import socket
|
|
import os
|
|
from .events import SystemEvent
|
|
|
|
try:
|
|
from BaseHTTPServer import HTTPServer
|
|
except ImportError as e:
|
|
from http.server import HTTPServer
|
|
|
|
if not self._config['http-enabled']:
|
|
return
|
|
|
|
# Setup
|
|
try:
|
|
|
|
# Create web hook request handler class
|
|
WebhookRequestHandler = WebhookRequestHandlerFactory(self._config, self._event_store, self._server_status, is_https=False)
|
|
|
|
# Create HTTP server
|
|
self._http_server = HTTPServer((self._config['http-host'],
|
|
self._config['http-port']),
|
|
WebhookRequestHandler)
|
|
|
|
# Setup SSL for HTTP server
|
|
sa = self._http_server.socket.getsockname()
|
|
self._http_port = sa[1]
|
|
self._server_status['http-uri'] = "http://%s:%s" % (self._config['http-host'], sa[1])
|
|
self._startup_event.log_info("Listening for connections on %s" % self._server_status['http-uri'])
|
|
self._startup_event.http_address = sa[0]
|
|
self._startup_event.http_port = sa[1]
|
|
self._startup_event.set_http_started(True)
|
|
|
|
except socket.error as e:
|
|
self._startup_event.log_critical("Unable to start HTTP server: %s" % e)
|
|
return
|
|
|
|
if not serve_forever:
|
|
return
|
|
|
|
# Run forever
|
|
try:
|
|
self._http_server.serve_forever()
|
|
|
|
except socket.error as e:
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
event.log_critical("Error on socket: %s" % e)
|
|
sys.exit(1)
|
|
|
|
except KeyboardInterrupt as e:
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
event.log_info('Requested close by keyboard interrupt signal')
|
|
self.stop()
|
|
self.exit()
|
|
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
event.log_info('HTTP server did quit')
|
|
|
|
def serve_https(self):
|
|
"""Starts a HTTPS server that listens for webhook requests and serves the web ui."""
|
|
import sys
|
|
import socket
|
|
import os
|
|
import ssl
|
|
from .events import SystemEvent
|
|
|
|
try:
|
|
from BaseHTTPServer import HTTPServer
|
|
except ImportError as e:
|
|
from http.server import HTTPServer
|
|
|
|
if not self._config['https-enabled']:
|
|
return
|
|
|
|
if not os.path.isfile(self._config['ssl-cert']):
|
|
self._startup_event.log_critical("Unable to activate SSL: File does not exist: %s" % self._config['ssl-cert'])
|
|
return
|
|
|
|
# Setup
|
|
try:
|
|
|
|
# Create web hook request handler class
|
|
WebhookRequestHandler = WebhookRequestHandlerFactory(self._config, self._event_store, self._server_status, is_https=True)
|
|
|
|
# Create HTTP server
|
|
self._https_server = HTTPServer((self._config['https-host'],
|
|
self._config['https-port']),
|
|
WebhookRequestHandler)
|
|
|
|
# Setup SSL for HTTP server
|
|
self._https_server_unwrapped_socket = self._https_server.socket
|
|
self._https_server.socket = ssl.wrap_socket(self._https_server.socket,
|
|
keyfile=self._config['ssl-key'],
|
|
certfile=self._config['ssl-cert'],
|
|
server_side=True)
|
|
|
|
sa = self._https_server.socket.getsockname()
|
|
self._http_port = sa[1]
|
|
self._server_status['https-uri'] = "https://%s:%s" % (self._config['https-host'], sa[1])
|
|
|
|
self._startup_event.log_info("Listening for connections on %s" % self._server_status['https-uri'])
|
|
self._startup_event.http_address = sa[0]
|
|
self._startup_event.http_port = sa[1]
|
|
self._startup_event.set_http_started(True)
|
|
|
|
except socket.error as e:
|
|
self._startup_event.log_critical("Unable to start HTTPS server: %s" % e)
|
|
return
|
|
|
|
# Run forever
|
|
try:
|
|
self._https_server.serve_forever()
|
|
|
|
except socket.error as e:
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
event.log_critical("Error on socket: %s" % e)
|
|
sys.exit(1)
|
|
|
|
except KeyboardInterrupt as e:
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
event.log_info('Requested close by keyboard interrupt signal')
|
|
self.stop()
|
|
self.exit()
|
|
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
event.log_info('HTTPS server did quit')
|
|
|
|
def serve_wss(self):
|
|
"""Start a web socket server over SSL, used by the web UI to get notifications about updates."""
|
|
import os
|
|
from .events import SystemEvent
|
|
|
|
# Start a web socket server if the web UI is enabled
|
|
if not self._config['web-ui-enabled']:
|
|
return
|
|
|
|
if not self._config['wss-enabled']:
|
|
return
|
|
|
|
if not os.path.isfile(self._config['ssl-cert']):
|
|
self._startup_event.log_critical("Unable to activate SSL: File does not exist: %s" % self._config['ssl-cert'])
|
|
return
|
|
|
|
try:
|
|
import os
|
|
from autobahn.websocket import WebSocketServerProtocol, WebSocketServerFactory
|
|
from twisted.internet import reactor, ssl
|
|
from twisted.internet.error import BindError
|
|
|
|
# Create a WebSocketClientHandler instance
|
|
WebSocketClientHandler = WebSocketClientHandlerFactory(self._config, self._ws_clients, self._event_store, self._server_status)
|
|
|
|
uri = u"ws://%s:%s" % (self._config['wss-host'], self._config['wss-port'])
|
|
factory = WebSocketServerFactory(uri)
|
|
factory.protocol = WebSocketClientHandler
|
|
# factory.setProtocolOptions(maxConnections=2)
|
|
|
|
# note to self: if using putChild, the child must be bytes...
|
|
if self._config['ssl-key'] and self._config['ssl-cert']:
|
|
contextFactory = ssl.DefaultOpenSSLContextFactory(privateKeyFileName=self._config['ssl-key'], certificateFileName=self._config['ssl-cert'])
|
|
else:
|
|
contextFactory = ssl.DefaultOpenSSLContextFactory(privateKeyFileName=self._config['ssl-cert'], certificateFileName=self._config['ssl-cert'])
|
|
|
|
|
|
self._ws_server_port = reactor.listenSSL(self._config['wss-port'], factory, contextFactory)
|
|
# self._ws_server_port = reactor.listenTCP(self._config['wss-port'], factory)
|
|
|
|
self._server_status['wss-uri'] = "wss://%s:%s" % (self._config['wss-host'], self._config['wss-port'])
|
|
|
|
self._startup_event.log_info("Listening for connections on %s" % self._server_status['wss-uri'])
|
|
self._startup_event.ws_address = self._config['wss-host']
|
|
self._startup_event.ws_port = self._config['wss-port']
|
|
self._startup_event.set_ws_started(True)
|
|
|
|
# Serve forever (until reactor.stop())
|
|
reactor.run(installSignalHandlers=False)
|
|
|
|
except BindError as e:
|
|
self._startup_event.log_critical("Unable to start web socket server: %s" % e)
|
|
|
|
except ImportError:
|
|
self._startup_event.log_error("Unable to start web socket server due to missing dependency.")
|
|
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
event.log_info('WSS server did quit')
|
|
|
|
def serve_forever(self):
|
|
"""Start HTTP and web socket servers."""
|
|
import sys
|
|
import socket
|
|
import logging
|
|
import os
|
|
from .events import SystemEvent
|
|
import threading
|
|
|
|
try:
|
|
from autobahn.websocket import WebSocketServerProtocol, WebSocketServerFactory
|
|
from twisted.internet import reactor
|
|
|
|
# Given that the nessecary dependencies are present, notify the
|
|
# event that we expect the web socket server to be started
|
|
self._startup_event.ws_started = False
|
|
except ImportError:
|
|
pass
|
|
|
|
# Notify the event that we expect the http server to be started
|
|
self._startup_event.http_started = False
|
|
|
|
# Add script dir to sys path, allowing us to import sub modules even after changing cwd
|
|
sys.path.insert(1, os.path.dirname(os.path.realpath(__file__)))
|
|
|
|
# Set CWD to public www folder. This makes the http server serve files from the wwwroot directory.
|
|
wwwroot = os.path.join(os.path.dirname(os.path.realpath(__file__)), "wwwroot")
|
|
os.chdir(wwwroot)
|
|
|
|
threads = [
|
|
# HTTP server
|
|
threading.Thread(target=self.serve_http),
|
|
|
|
# HTTPS server
|
|
threading.Thread(target=self.serve_https),
|
|
|
|
# Web socket SSL server
|
|
threading.Thread(target=self.serve_wss)
|
|
]
|
|
|
|
# Start all threads
|
|
for thread in threads:
|
|
thread.start()
|
|
|
|
# Wait for each thread to finish
|
|
for thread in threads:
|
|
|
|
# Wait for thread to finish without blocking main thread
|
|
while thread.is_alive():
|
|
thread.join(5)
|
|
|
|
def signal_handler(self, signum, frame):
|
|
from .events import SystemEvent
|
|
self.stop()
|
|
|
|
event = SystemEvent()
|
|
self._event_store.register_action(event)
|
|
|
|
# Reload configuration on SIGHUP events (conventional for daemon processes)
|
|
if signum == 1:
|
|
self.setup(self._config)
|
|
self.serve_forever()
|
|
return
|
|
|
|
# Keyboard interrupt signal
|
|
elif signum == 2:
|
|
event.log_info('Recieved keyboard interrupt signal (%s) from the OS, shutting down.' % signum)
|
|
|
|
else:
|
|
event.log_info('Recieved signal (%s) from the OS, shutting down.' % signum)
|
|
|
|
self.exit()
|
|
|
|
def stop(self):
|
|
"""Stop all running TCP servers (HTTP and web socket servers)"""
|
|
|
|
# Stop HTTP server if running
|
|
if self._http_server is not None:
|
|
|
|
# Shut down the underlying TCP server
|
|
self._http_server.shutdown()
|
|
|
|
# Close the socket
|
|
self._http_server.socket.close()
|
|
|
|
# Stop HTTPS server if running
|
|
if self._https_server is not None:
|
|
|
|
# Shut down the underlying TCP server
|
|
self._https_server.shutdown()
|
|
|
|
# Close the socket
|
|
self._https_server.socket.close()
|
|
|
|
if self._https_server_unwrapped_socket is not None:
|
|
|
|
self._https_server_unwrapped_socket.close()
|
|
|
|
# Stop web socket server if running
|
|
try:
|
|
from twisted.internet import reactor
|
|
reactor.callFromThread(reactor.stop)
|
|
except ImportError:
|
|
pass
|
|
|
|
def exit(self):
|
|
import sys
|
|
import logging
|
|
logger = logging.getLogger()
|
|
logger.info('Goodbye')
|
|
|
|
# Delete PID file
|
|
self.remove_pid_file()
|
|
|
|
# Restore stdin and stdout
|
|
if 'intercept-stdout' in self._config and self._config['intercept-stdout']:
|
|
sys.stdout = self._default_stdout
|
|
sys.stderr = self._default_stderr
|
|
|
|
|
|
def main():
|
|
import signal
|
|
from gitautodeploy import GitAutoDeploy
|
|
from cli.config import get_config_defaults, get_config_from_environment
|
|
from cli.config import get_config_from_argv, find_config_file
|
|
from cli.config import get_config_from_file, get_repo_config_from_environment
|
|
from cli.config import init_config, get_config_file_path, rename_legacy_attribute_names
|
|
from cli.config import ConfigFileNotFoundException, ConfigFileInvalidException
|
|
import logging
|
|
import sys
|
|
import os
|
|
|
|
logger = logging.getLogger()
|
|
|
|
app = GitAutoDeploy()
|
|
|
|
if hasattr(signal, 'SIGHUP'):
|
|
signal.signal(signal.SIGHUP, app.signal_handler)
|
|
if hasattr(signal, 'SIGINT'):
|
|
signal.signal(signal.SIGINT, app.signal_handler)
|
|
if hasattr(signal, 'SIGABRT'):
|
|
signal.signal(signal.SIGABRT, app.signal_handler)
|
|
if hasattr(signal, 'SIGPIPE') and hasattr(signal, 'SIG_IGN'):
|
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
|
|
|
# Get default config values
|
|
config = get_config_defaults()
|
|
|
|
# Get config values from environment variables and commadn line arguments
|
|
environment_config = get_config_from_environment()
|
|
argv_config = get_config_from_argv(sys.argv[1:])
|
|
|
|
# Merge config values from environment variables
|
|
config.update(environment_config)
|
|
|
|
search_target = os.path.dirname(os.path.realpath(__file__))
|
|
config_file_path = get_config_file_path(environment_config, argv_config, search_target)
|
|
|
|
# Config file path provided or found?
|
|
if config_file_path:
|
|
|
|
try:
|
|
file_config = get_config_from_file(config_file_path)
|
|
except ConfigFileNotFoundException as e:
|
|
app.setup_console_logger()
|
|
logger.critical("No config file not found at '%s'" % e)
|
|
return
|
|
except ConfigFileInvalidException as e:
|
|
app.setup_console_logger()
|
|
logger.critical("Unable to read config file due to invalid JSON format in '%s'" % e)
|
|
return
|
|
|
|
# Merge config values from config file (overrides environment variables)
|
|
config.update(file_config)
|
|
|
|
# Merge config value from command line (overrides environment variables and config file)
|
|
config.update(argv_config)
|
|
|
|
# Rename legacy config option names
|
|
config = rename_legacy_attribute_names(config)
|
|
|
|
# Extend config data with any repository defined by environment variables
|
|
repo_config = get_repo_config_from_environment()
|
|
|
|
if repo_config:
|
|
|
|
if not 'repositories' in config:
|
|
config['repositories'] = []
|
|
|
|
config['repositories'].append(repo_config)
|
|
|
|
# Initialize config by expanding with missing values
|
|
init_config(config)
|
|
|
|
app.setup(config)
|
|
app.serve_forever()
|