Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to HTTP Inspector #2626

Merged
merged 17 commits into from Dec 18, 2022
Merged
3 changes: 3 additions & 0 deletions sanic/app.py
Expand Up @@ -140,6 +140,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"configure_logging",
"ctx",
"error_handler",
"inspector_class",
"go_fast",
"listeners",
"multiplexer",
Expand Down Expand Up @@ -176,6 +177,7 @@ def __init__(
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
) -> None:
super().__init__(name=name)
# logging
Expand Down Expand Up @@ -211,6 +213,7 @@ def __init__(
self.configure_logging: bool = configure_logging
self.ctx: Any = ctx or SimpleNamespace()
self.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.inspector_class: Type[Inspector] = inspector_class or Inspector
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {}
self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {}
Expand Down
114 changes: 67 additions & 47 deletions sanic/cli/app.py
Expand Up @@ -3,23 +3,20 @@
import shutil
import sys

from argparse import ArgumentParser, RawTextHelpFormatter
from functools import partial
from textwrap import indent
from typing import Any, List, Union

from sanic.app import Sanic
from sanic.application.logo import get_logo
from sanic.cli.arguments import Group
from sanic.log import error_logger
from sanic.worker.inspector import inspect
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.cli.inspector import make_inspector_parser
from sanic.log import Colors, error_logger
from sanic.worker.inspector import InspectorClient
from sanic.worker.loader import AppLoader


class SanicArgumentParser(ArgumentParser):
...


class SanicCLI:
DESCRIPTION = indent(
f"""
Expand All @@ -46,7 +43,7 @@ def __init__(self) -> None:
self.parser = SanicArgumentParser(
prog="sanic",
description=self.DESCRIPTION,
formatter_class=lambda prog: RawTextHelpFormatter(
formatter_class=lambda prog: SanicHelpFormatter(
prog,
max_help_position=36 if width > 96 else 24,
indent_increment=4,
Expand All @@ -60,14 +57,25 @@ def __init__(self) -> None:
)
self.args: List[Any] = []
self.groups: List[Group] = []
self.inspecting = False

def attach(self):
if sys.argv[1] == "inspect":
self.inspecting = True
self.parser.description = get_logo(True)
make_inspector_parser(self.parser)
return

ahopkins marked this conversation as resolved.
Show resolved Hide resolved
for group in Group._registry:
instance = group.create(self.parser)
instance.attach()
self.groups.append(instance)

def run(self, parse_args=None):
if self.inspecting:
self._inspector()
return

legacy_version = False
if not parse_args:
# This is to provide backwards compat -v to display version
Expand All @@ -76,7 +84,7 @@ def run(self, parse_args=None):
elif parse_args == ["-v"]:
parse_args = ["--version"]

if not legacy_version:
if not legacy_version and not self.inspecting:
ahopkins marked this conversation as resolved.
Show resolved Hide resolved
parsed, unknown = self.parser.parse_known_args(args=parse_args)
if unknown and parsed.factory:
for arg in unknown:
Expand All @@ -86,52 +94,21 @@ def run(self, parse_args=None):
self.args = self.parser.parse_args(args=parse_args)
self._precheck()
app_loader = AppLoader(
self.args.module,
self.args.factory,
self.args.simple,
self.args,
self.args.module, self.args.factory, self.args.simple, self.args
)

if self.args.inspect or self.args.inspect_raw or self.args.trigger:
self._inspector_legacy(app_loader)
return

try:
app = self._get_app(app_loader)
kwargs = self._build_run_kwargs()
except ValueError as e:
error_logger.exception(f"Failed to run app: {e}")
else:
if (
self.args.inspect
or self.args.inspect_raw
or self.args.trigger
or self.args.scale is not None
):
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "true"
else:
for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)

if (
self.args.inspect
or self.args.inspect_raw
or self.args.trigger
or self.args.scale is not None
):
if self.args.scale is not None:
if self.args.scale <= 0:
error_logger.error("There must be at least 1 worker")
sys.exit(1)
action = f"scale={self.args.scale}"
else:
action = self.args.trigger or (
"raw" if self.args.inspect_raw else "pretty"
)
inspect(
app.config.INSPECTOR_HOST,
app.config.INSPECTOR_PORT,
action,
)
del os.environ["SANIC_IGNORE_PRODUCTION_WARNING"]
return

for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)
if self.args.single:
serve = Sanic.serve_single
elif self.args.legacy:
Expand All @@ -140,6 +117,49 @@ def run(self, parse_args=None):
serve = partial(Sanic.serve, app_loader=app_loader)
serve(app)

def _inspector_legacy(self, app_loader: AppLoader):
host = port = None
if ":" in self.args.module:
maybe_host, maybe_port = self.args.module.rsplit(":", 1)
if maybe_port.isnumeric():
host, port = maybe_host, int(maybe_port)
if not host:
app = self._get_app(app_loader)
host, port = app.config.INSPECTOR_HOST, app.config.INSPECTOR_PORT

action = self.args.trigger or "info"

InspectorClient(host, port, False, self.args.inspect_raw).do(action)
sys.stdout.write(
f"\n{Colors.BOLD}{Colors.YELLOW}WARNING:{Colors.END} "
"You are using the legacy CLI command that will be removed in "
f"{Colors.RED}v23.3{Colors.END}. See ___ or checkout the new "
"style commands:\n\n\t"
f"{Colors.YELLOW}sanic inspect --help{Colors.END}\n"
)

def _inspector(self):
args = sys.argv[2:]
self.args, unknown = self.parser.parse_known_args(args=args)
if unknown:
for arg in unknown:
if arg.startswith("--"):
key, value = arg.split("=")
setattr(self.args, key.strip("-"), value)
ahopkins marked this conversation as resolved.
Show resolved Hide resolved

kwargs = {**self.args.__dict__}
host = kwargs.pop("host")
port = kwargs.pop("port")
secure = kwargs.pop("secure")
raw = kwargs.pop("raw")
action = kwargs.pop("action") or "info"
positional = kwargs.pop("positional", None)
if action == "<custom>" and positional:
action = positional[0]
if len(positional) > 1:
kwargs["args"] = positional[1:]
InspectorClient(host, port, secure, raw).do(action, **kwargs)

def _precheck(self):
# Custom TLS mismatch handling for better diagnostics
if self.main_process and (
Expand Down
6 changes: 0 additions & 6 deletions sanic/cli/arguments.py
Expand Up @@ -115,12 +115,6 @@ def attach(self):
const="shutdown",
help=("Trigger all processes to shutdown"),
)
group.add_argument(
"--scale",
dest="scale",
type=int,
help=("Scale number of workers"),
)


class HTTPVersionGroup(Group):
Expand Down
29 changes: 29 additions & 0 deletions sanic/cli/base.py
@@ -0,0 +1,29 @@
from argparse import (
Action,
ArgumentParser,
RawTextHelpFormatter,
_SubParsersAction,
)
from typing import Any


class SanicArgumentParser(ArgumentParser):
def _check_value(self, action: Action, value: Any) -> None:
if isinstance(action, SanicSubParsersAction):
return
super()._check_value(action, value)


class SanicHelpFormatter(RawTextHelpFormatter):
...


class SanicSubParsersAction(_SubParsersAction):
def __call__(self, parser, namespace, values, option_string=None):
self._name_parser_map
parser_name = values[0]
if parser_name not in self._name_parser_map:
self._name_parser_map[parser_name] = parser
values = ["<custom>", *values]

super().__call__(parser, namespace, values, option_string)
47 changes: 47 additions & 0 deletions sanic/cli/inspector.py
@@ -0,0 +1,47 @@
from argparse import ArgumentParser

from sanic.cli.base import SanicHelpFormatter, SanicSubParsersAction


def make_inspector_parser(parser: ArgumentParser) -> None:
parser.add_argument("--host", "-H", default="localhost")
parser.add_argument("--port", "-p", default=6457, type=int)
parser.add_argument("--secure", "-s", action="store_true")
parser.add_argument(
"--raw",
action="store_true",
help="Whether to output the raw response information",
)

subparsers = parser.add_subparsers(
action=SanicSubParsersAction,
dest="action",
description=(
"Run one of the below subcommands. If you have created a custom "
"Inspector instance, then you can run custom commands.\nSee ___ "
ahopkins marked this conversation as resolved.
Show resolved Hide resolved
"for more details."
),
title="Subcommands",
)
subparsers.add_parser(
"reload", help="Trigger a reload of the server workers"
)
subparsers.add_parser(
"shutdown", help="Shutdown the application and all processes"
)
scale = subparsers.add_parser("scale", help="Scale the number of workers")
scale.add_argument("replicas", type=int)

custom = subparsers.add_parser(
"<custom>",
help="Run a custom command",
description=(
"keyword arguments:\n When running a custom command, you can "
"add keyword arguments by appending them to your command\n\n"
"\tsanic inspect foo --one=1 --two=2"
),
formatter_class=SanicHelpFormatter,
)
custom.add_argument(
"positional", nargs="*", help="Add one or more non-keyword args"
)
4 changes: 4 additions & 0 deletions sanic/config.py
Expand Up @@ -46,6 +46,8 @@
"INSPECTOR": False,
"INSPECTOR_HOST": "localhost",
"INSPECTOR_PORT": 6457,
"INSPECTOR_TLS_KEY": _default,
"INSPECTOR_TLS_CERT": _default,
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
Expand Down Expand Up @@ -93,6 +95,8 @@ class Config(dict, metaclass=DescriptorMeta):
INSPECTOR: bool
INSPECTOR_HOST: str
INSPECTOR_PORT: int
INSPECTOR_TLS_KEY: Union[Path, str, Default]
INSPECTOR_TLS_CERT: Union[Path, str, Default]
KEEP_ALIVE_TIMEOUT: int
KEEP_ALIVE: bool
LOCAL_CERT_CREATOR: Union[str, LocalCertCreator]
Expand Down
8 changes: 5 additions & 3 deletions sanic/http/tls/context.py
Expand Up @@ -24,14 +24,16 @@ def create_context(
certfile: Optional[str] = None,
keyfile: Optional[str] = None,
password: Optional[str] = None,
purpose: ssl.Purpose = ssl.Purpose.CLIENT_AUTH,
) -> ssl.SSLContext:
"""Create a context with secure crypto and HTTP/1.1 in protocols."""
context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
context = ssl.create_default_context(purpose=purpose)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.set_ciphers(":".join(CIPHERS_TLS12))
context.set_alpn_protocols(["http/1.1"])
context.sni_callback = server_name_callback
if certfile and keyfile:
if purpose is ssl.Purpose.CLIENT_AUTH:
context.sni_callback = server_name_callback
if certfile or keyfile:
context.load_cert_chain(certfile, keyfile, password)
return context

Expand Down
7 changes: 4 additions & 3 deletions sanic/mixins/startup.py
Expand Up @@ -58,7 +58,6 @@
from sanic.server.protocols.websocket_protocol import WebSocketProtocol
from sanic.server.runners import serve, serve_multiple, serve_single
from sanic.server.socket import configure_socket, remove_unix_socket
from sanic.worker.inspector import Inspector
from sanic.worker.loader import AppLoader
from sanic.worker.manager import WorkerManager
from sanic.worker.multiplexer import WorkerMultiplexer
Expand Down Expand Up @@ -835,14 +834,16 @@ def serve(
"packages": [sanic_version, *packages],
"extra": extra,
}
inspector = Inspector(
inspector = primary.inspector_class(
monitor_pub,
app_info,
worker_state,
primary.config.INSPECTOR_HOST,
primary.config.INSPECTOR_PORT,
primary.config.INSPECTOR_TLS_KEY,
primary.config.INSPECTOR_TLS_CERT,
)
manager.manage("Inspector", inspector, {}, transient=False)
manager.manage("Inspector", inspector, {}, transient=True)

primary._inspector = inspector
primary._manager = manager
Expand Down