atr/server.py (166 lines of code) (raw):
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""server.py"""
import logging
import os
from collections.abc import Iterable
from types import ModuleType
from typing import Any
import asfquart
import asfquart.base as base
import asfquart.generics
import asfquart.session
import blockbuster
import quart
import quart_schema
import rich.logging as rich_logging
import werkzeug.routing as routing
import atr
import atr.blueprints as blueprints
import atr.config as config
import atr.db as db
import atr.filters as filters
import atr.manager as manager
import atr.preload as preload
import atr.ssh as ssh
import atr.user as user
import atr.util as util
# TODO: Technically this is a global variable
# We should probably find a cleaner way to do this
app: base.QuartApp | None = None
# Avoid OIDC
asfquart.generics.OAUTH_URL_INIT = "https://oauth.apache.org/auth?state=%s&redirect_uri=%s"
asfquart.generics.OAUTH_URL_CALLBACK = "https://oauth.apache.org/token?code=%s"
class ApiOnlyOpenAPIProvider(quart_schema.OpenAPIProvider):
def generate_rules(self) -> Iterable[routing.Rule]:
for rule in super().generate_rules():
if rule.rule.startswith("/api"):
yield rule
def app_create_base(app_config: type[config.AppConfig]) -> base.QuartApp:
"""Create the base Quart application."""
if asfquart.construct is ...:
raise ValueError("asfquart.construct is not set")
app = asfquart.construct(__name__)
app.config.from_object(app_config)
return app
def app_dirs_setup(app_config: type[config.AppConfig]) -> None:
"""Setup application directories."""
if not os.path.isdir(app_config.STATE_DIR):
raise RuntimeError(f"State directory not found: {app_config.STATE_DIR}")
os.chdir(app_config.STATE_DIR)
print(f"Working directory changed to: {os.getcwd()}")
util.get_unfinished_dir().mkdir(parents=True, exist_ok=True)
util.get_finished_dir().mkdir(parents=True, exist_ok=True)
def app_setup_api_docs(app: base.QuartApp) -> None:
"""Configure OpenAPI documentation."""
import quart_schema
import atr.metadata as metadata
quart_schema.QuartSchema(
app,
info=quart_schema.Info(
title="ATR API",
description="OpenAPI documentation for the Apache Trusted Release Platform.",
version=metadata.version,
),
openapi_provider_class=ApiOnlyOpenAPIProvider,
swagger_ui_path="/api/docs",
openapi_path="/api/openapi.json",
)
def app_setup_context(app: base.QuartApp) -> None:
"""Setup application context processor."""
@app.context_processor
async def app_wide() -> dict[str, Any]:
import atr.metadata as metadata
import atr.routes.mapping as mapping
import atr.routes.modules as modules
return {
"as_url": util.as_url,
"commit": metadata.commit,
"current_user": await asfquart.session.read(),
"is_admin_fn": user.is_admin,
"is_viewing_as_admin_fn": util.is_user_viewing_as_admin,
"is_committee_member_fn": user.is_committee_member,
"routes": modules,
"unfinished_releases_fn": db.unfinished_releases,
"release_as_url": mapping.release_as_url,
"version": metadata.version,
}
def app_setup_lifecycle(app: base.QuartApp) -> None:
"""Setup application lifecycle hooks."""
@app.before_serving
async def startup() -> None:
"""Start services before the app starts serving requests."""
worker_manager = manager.get_worker_manager()
await worker_manager.start()
ssh_server = await ssh.server_start()
app.extensions["ssh_server"] = ssh_server
@app.after_serving
async def shutdown() -> None:
"""Clean up services after the app stops serving requests."""
worker_manager = manager.get_worker_manager()
await worker_manager.stop()
ssh_server = app.extensions.get("ssh_server")
if ssh_server:
await ssh.server_stop(ssh_server)
await db.shutdown_database()
app.background_tasks.clear()
def app_setup_logging(app: base.QuartApp, config_mode: config.Mode, app_config: type[config.AppConfig]) -> None:
"""Setup application logging."""
logging.basicConfig(
format="[%(asctime)s.%(msecs)03d ] [%(process)d] %(message)s",
level=logging.INFO,
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[rich_logging.RichHandler(rich_tracebacks=True, show_time=False)],
)
# enable debug output for atr.* in DEBUG mode
if config_mode == config.Mode.Debug:
logging.getLogger(atr.__name__).setLevel(logging.DEBUG)
# Only log in the worker process
@app.before_serving
async def log_debug_info() -> None:
if config_mode == config.Mode.Debug or config_mode == config.Mode.Profiling:
app.logger.info(f"DEBUG = {config_mode == config.Mode.Debug}")
app.logger.info(f"ENVIRONMENT = {config_mode.value}")
app.logger.info(f"STATE_DIR = {app_config.STATE_DIR}")
def create_app(app_config: type[config.AppConfig]) -> base.QuartApp:
"""Create and configure the application."""
app_dirs_setup(app_config)
app = app_create_base(app_config)
app_setup_api_docs(app)
db.init_database(app)
register_routes(app)
blueprints.register(app)
filters.register_filters(app)
config_mode = config.get_mode()
app_setup_context(app)
app_setup_lifecycle(app)
app_setup_logging(app, config_mode, app_config)
# do not enable template pre-loading if we explicitly want to reload templates
if not app_config.TEMPLATES_AUTO_RELOAD:
preload.setup_template_preloading(app)
@app.before_serving
async def start_blockbuster() -> None:
# "I'll have a P, please, Bob."
bb: blockbuster.BlockBuster | None = None
if config_mode == config.Mode.Profiling:
bb = blockbuster.BlockBuster()
app.extensions["blockbuster"] = bb
if bb is not None:
bb.activate()
app.logger.info("Blockbuster activated to detect blocking calls")
@app.after_serving
async def stop_blockbuster() -> None:
bb = app.extensions.get("blockbuster")
if bb is not None:
bb.deactivate()
app.logger.info("Blockbuster deactivated")
return app
def main() -> None:
"""Quart debug server"""
global app
if app is None:
app = create_app(config.get())
app.run(port=8080, ssl_keyfile="key.pem", ssl_certfile="cert.pem")
def register_routes(app: base.QuartApp) -> ModuleType:
# NOTE: These imports are for their side effects only
import atr.routes.modules as modules
# Add a global error handler to show helpful error messages with tracebacks
@app.errorhandler(Exception)
async def handle_any_exception(error: Exception) -> Any:
import traceback
# Required to give to the error.html template
tb = traceback.format_exc()
app.logger.exception("Unhandled exception")
return await quart.render_template("error.html", error=str(error), traceback=tb, status_code=500), 500
@app.errorhandler(base.ASFQuartException)
async def handle_asfquart_exception(error: base.ASFQuartException) -> Any:
# TODO: Figure out why pyright doesn't know about this attribute
if not hasattr(error, "errorcode"):
errorcode = 500
else:
errorcode = getattr(error, "errorcode")
return await quart.render_template("error.html", error=str(error), status_code=errorcode), errorcode
# Add a global error handler in case a page does not exist.
@app.errorhandler(404)
async def handle_not_found(error: Exception) -> Any:
return await quart.render_template("notfound.html", error="404 Not Found", traceback="", status_code=404), 404
return modules
# FIXME: when running in SSL mode, you will receive these exceptions upon termination at times:
# ssl.SSLError: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2706)
# related ticket: https://github.com/pgjones/hypercorn/issues/261
# in production, we actually do not need SSL mode as SSL termination is handled by the apache reverse proxy.
# the tooling-agenda app runs without SSL on agenda-test in a similar setup and it works fine.
if __name__ == "__main__":
main()
else:
app = create_app(config.get())